type-predicates-generator
Version:
Predicate and assert functions generator from type definitions.
768 lines (758 loc) • 25.4 kB
JavaScript
// src/generate/index.ts
import { writeFileSync } from "fs";
import { resolve as resolve2, relative } from "path";
import * as glob from "glob";
import { WatchFileKind } from "typescript";
// src/compiler-api/compiler-api-handler.ts
import * as ts from "typescript";
import { forEachChild, unescapeLeadingUnderscores as unescapeLeadingUnderscores2 } from "typescript";
// src/type-object.ts
function primitive(kind) {
return {
__type: "PrimitiveTO",
kind
};
}
function special(kind) {
return {
__type: "SpecialTO",
kind
};
}
function skip() {
return {
__type: "SkipTO"
};
}
// src/utils.ts
function isOk(result) {
return result.__type === "ok";
}
function isNg(result) {
return result.__type === "ng";
}
function ok(value) {
return {
__type: "ok",
ok: value
};
}
function ng(value) {
return {
__type: "ng",
ng: value
};
}
var toResult = (target, isParentMatch, resolveParent, parentResolved) => {
const resolved = typeof parentResolved === "undefined" ? isParentMatch(target) ? resolveParent(target) : void 0 : parentResolved;
return {
resolved,
default: (resolveDefault) => resolved ?? resolveDefault(target),
case: (isMatch, resolve3) => toResult(target, isMatch, resolve3, resolved)
};
};
var switchExpression = (target) => {
return {
resolved: void 0,
default: (resolveDefault) => resolveDefault(target),
case: (isMatch, resolve3) => toResult(target, isMatch, resolve3, void 0)
};
};
// src/compiler-api/adaptor.ts
var NodeAdaptor = class {
constructor(base) {
this.base = base;
}
get type() {
return "type" in this.base ? this.base.type : void 0;
}
get symbol() {
return "symbol" in this.base ? this.base.symbol : void 0;
}
};
var TypeAdaptor = class {
constructor(base) {
this.base = base;
}
get types() {
return "types" in this.base ? this.base.types : [];
}
get resolvedTypeArguments() {
return "resolvedTypeArguments" in this.base ? this.base.resolvedTypeArguments : [];
}
get value() {
return "value" in this.base ? this.base.value : void 0;
}
get node() {
return "node" in this.base ? this.base.node : void 0;
}
};
// src/compiler-api/compiler-api-handler.ts
var CompilerApiHandler = class {
#program;
#typeChecker;
constructor(program) {
this.#program = program;
this.#typeChecker = this.#program.getTypeChecker();
}
extractTypes(filePath) {
const sourceFile = this.#program.getSourceFile(filePath);
if (!sourceFile) {
return ng({
reason: "fileNotFound"
});
}
const nodes = this.#extractNodes(sourceFile).filter(
(node) => ts.isExportDeclaration(node) || ts.isEnumDeclaration(node) || (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && // @ts-expect-error exclude not exported type def
typeof node?.localSymbol !== "undefined"
).filter(
(node) => this.#isTypeParametersResolved(
this.#typeChecker.getTypeAtLocation(node)
)
);
return ok(
nodes.map((node) => new NodeAdaptor(node)).flatMap((node) => {
if (ts.isExportDeclaration(node.base)) {
const nodes2 = this.#extractTypesFromExportDeclaration(node.base);
if (isOk(nodes2)) {
return nodes2.ok;
} else {
return ng({
reason: "exportError",
meta: nodes2.ng.reason
});
}
}
return {
typeName: typeof node?.symbol?.escapedName !== "undefined" ? String(node?.symbol?.escapedName) : void 0,
type: this.#convertType(
this.#typeChecker.getTypeAtLocation(node.base)
)
};
}).filter(
(result) => {
if ("__type" in result && isNg(result)) {
console.log(`Skip reason: ${result.ng.meta}`);
return false;
}
return true;
}
)
);
}
// Only support named-export
#extractTypesFromExportDeclaration(declare) {
const path = declare.moduleSpecifier?.getText();
if (!path)
return ng({
reason: "fileNotFound"
});
const sourceFile = declare.getSourceFile();
const moduleMap = (
// @ts-expect-error: type def wrong
sourceFile.resolvedModules
);
if (!moduleMap)
return ng({
reason: "resolvedModulesNotFound"
});
const module = moduleMap.get(
ts.escapeLeadingUnderscores(path.replace(/'/g, "").replace(/"/g, ""))
);
if (!module)
return ng({
reason: "moduleNotFound"
});
const types = this.extractTypes(module.resolvedFileName);
if (isNg(types))
return ng({ reason: "moduleFileNotFound" });
const clause = declare.exportClause;
if (!clause)
return ng({
reason: "unknown"
});
if (ts.isNamedExports(clause)) {
return ok(
clause.elements.map((node) => new NodeAdaptor(node)).map(({ symbol }) => symbol?.getEscapedName()).filter((str) => typeof str !== "undefined").map((str) => ts.unescapeLeadingUnderscores(str)).map(
(key) => types.ok.find(({ typeName }) => typeName === key) ?? {
typeName: key,
type: skip()
}
)
);
}
return ng({
reason: "notNamedExport"
});
}
#extractNodes(sourceFile) {
const nodes = [];
forEachChild(sourceFile, (node) => {
nodes.push(node);
});
return nodes;
}
#createObjectType(tsType) {
return {
__type: "ObjectTO",
tsType,
typeName: this.#typeToString(tsType),
getProps: () => this.#typeChecker.getPropertiesOfType(tsType).map(
(symbol) => {
const declare = (symbol.declarations ?? [])[0];
const type = declare ? this.#typeChecker.getTypeOfSymbolAtLocation(symbol, declare) : void 0;
return {
propName: String(symbol.escapedName),
type: type ? this.#isCallable(type) ? skip() : this.#convertType(type) : {
__type: "UnknownTO",
kind: "prop"
}
};
}
).filter((typeObject) => typeObject.type.__type !== "SkipTO")
};
}
#extractArrayTFromTypeNode(typeNode) {
return this.#convertType(
this.#typeChecker.getTypeAtLocation(typeNode.elementType)
);
}
#extractArrayT(rawType) {
const type = new TypeAdaptor(rawType);
const maybeArrayT = (type.resolvedTypeArguments ?? [])[0];
if (type.base.symbol.getEscapedName() === "Array" && typeof maybeArrayT !== "undefined") {
return ok(this.#convertType(maybeArrayT));
}
const maybeNode = type?.node;
if (!maybeNode) {
return ng({
reason: "node_not_defined"
});
}
if (ts.isTypeReferenceNode(maybeNode)) {
const [typeArg1] = this.#extractTypeArgumentsFromTypeRefNode(maybeNode);
return typeof typeArg1 !== "undefined" ? ok(typeArg1) : ng({
reason: "cannot_resolve"
});
}
if (!ts.isArrayTypeNode(maybeNode)) {
return ng({
reason: "not_array_type_node"
});
}
return ok(this.#extractArrayTFromTypeNode(maybeNode));
}
#extractTypeArgumentsFromTypeRefNode(node) {
return Array.from(node.typeArguments ?? []).map(
(arg) => this.#convertType(this.#typeChecker.getTypeFromTypeNode(arg))
);
}
#hasUnresolvedTypeParameter(type) {
if (!("typeName" in type)) {
return type.__type === "TypeParameterTO";
}
const deps = type.__type === "ObjectTO" ? type.getProps().map((prop) => prop.type) : type.__type === "ArrayTO" ? [type.child] : type.__type === "UnionTO" ? type.unions : [];
return deps.reduce(
(s, t) => s || t.__type === "TypeParameterTO" || "typeName" in t && t.typeName !== type.typeName && this.#hasUnresolvedTypeParameter(t),
false
);
}
#convertType(rawType) {
const type = new TypeAdaptor(rawType);
return switchExpression({
type,
typeNode: type.node,
typeText: this.#typeToString(type.base)
}).case(
({ type: type2 }) => type2.base.isUnion(),
({ typeText }) => ({
__type: "UnionTO",
typeName: typeText,
unions: (type?.types ?? []).map((type2) => this.#convertType(type2))
})
).case(
({ type: type2 }) => type2.base.isTypeParameter(),
({ typeText }) => ({
__type: "TypeParameterTO",
name: typeText
})
).case(
({ typeNode }) => typeof typeNode !== "undefined" && ts.isTupleTypeNode(typeNode),
({ typeText, typeNode }) => ({
__type: "TupleTO",
typeName: typeText,
items: typeNode.elements.map(
(typeNode2) => this.#convertType(this.#typeChecker.getTypeFromTypeNode(typeNode2))
)
})
).case(
({ type: type2 }) => type2.base.isLiteral(),
({ type: type2 }) => ({
__type: "LiteralTO",
value: type2.value
})
).case(
({ typeText }) => ["true", "false"].includes(typeText),
({ typeText }) => ({
__type: "LiteralTO",
value: typeText === "true" ? true : false
})
).case(
({ typeText }) => typeText === "string",
() => primitive("string")
).case(
({ typeText }) => typeText === "number",
() => primitive("number")
).case(
({ typeText }) => typeText === "bigint",
() => primitive("bigint")
).case(
({ typeText }) => typeText === "boolean",
() => primitive("boolean")
).case(
({ typeText }) => typeText === "null",
() => special("null")
).case(
({ typeText }) => typeText === "undefined",
() => special("undefined")
).case(
({ typeText }) => typeText === "void",
() => special("void")
).case(
({ typeText }) => typeText === "any",
() => special("any")
).case(
({ typeText }) => typeText === "unknown",
() => special("unknown")
).case(
({ typeText }) => typeText === "never",
() => special("never")
).case(
({ typeText }) => typeText === "Date",
() => special("Date")
).case(
({ type: type2, typeText }) => typeText.endsWith("[]") || type2.base.symbol?.escapedName === "Array",
({ type: type2, typeText }) => ({
__type: "ArrayTO",
typeName: typeText,
child: (() => {
const resultT = this.#extractArrayT(type2.base);
return isOk(resultT) ? resultT.ok : { __type: "UnknownTO", kind: "arrayT" };
})()
})
).case(
({ type: type2 }) => this.#typeChecker.getPropertiesOfType(type2.base).length !== 0,
({ type: type2 }) => this.#createObjectType(type2.base)
).default(({ typeText }) => ({
__type: "UnknownTO",
kind: "convert",
typeText
}));
}
#isCallable(type) {
return this.#getMembers(type).findIndex(
(member) => unescapeLeadingUnderscores2(member.getEscapedName()) === "__call"
) >= 0;
}
#getMembers(type) {
const members = [];
type.getSymbol()?.members?.forEach((memberSymbol) => {
members.push(memberSymbol);
});
return members;
}
#isTypeParametersResolved(type) {
return (type.aliasTypeArguments ?? []).length === 0 || // @ts-expect-error: wrong type def
type.typeParameter !== void 0;
}
#typeToString(type) {
return this.#typeChecker.typeToString(type).replace("typeof ", "");
}
};
// src/compiler-api/program.ts
import { resolve } from "path";
import {
sys,
readConfigFile,
parseJsonConfigFileContent,
createProgram as baseCreateProgram,
createWatchProgram,
createWatchCompilerHost,
createEmitAndSemanticDiagnosticsBuilderProgram
} from "typescript";
var createProgram = (tsConfigPath) => {
const configFile = readConfigFile(tsConfigPath, sys.readFile);
if (typeof configFile.error !== "undefined") {
throw new Error(`Failed to load tsconfig: ${configFile.error}`);
}
const { options, fileNames } = parseJsonConfigFileContent(
configFile.config,
{
fileExists: sys.fileExists,
readFile: sys.readFile,
readDirectory: sys.readDirectory,
useCaseSensitiveFileNames: true
},
resolve(tsConfigPath, "..")
);
return baseCreateProgram({
rootNames: fileNames,
options
});
};
function watchCompiler(tsConfigPath, watchFiles = [], onFileChanged, watchOption, reportDiagnostic, reportWatchStatus) {
const createProgram2 = createEmitAndSemanticDiagnosticsBuilderProgram;
const host = createWatchCompilerHost(
tsConfigPath,
{
noEmit: true
},
sys,
createProgram2,
reportDiagnostic,
reportWatchStatus,
watchOption
);
watchFiles.forEach((file) => {
host.watchFile(file, onFileChanged);
});
return createWatchProgram(host);
}
// src/generate/generate-type-predicates.ts
import { uniq } from "ramda";
var primitiveTypePredicateNameMap = {
string: "isString",
number: "isNumber",
bigint: "isBigint",
boolean: "isBoolean"
};
var specialTypePredicateNameMap = {
null: "isNull",
undefined: "isUndefined",
any: "isAny",
unknown: "isUnknown",
never: "isNever",
void: "isVoid",
Date: "isDate"
};
var reservedNames = [
"String",
"Number",
"Bigint",
"Boolean",
"Null",
"Undefined",
"Any",
"Unknown",
"Never",
"Void",
"Date",
"Object",
"Array",
"Union"
];
var primitiveTypePredicateMap = {
string: "const isString = (value: unknown): value is string => typeof value === 'string';",
number: "const isNumber = (value: unknown): value is number => typeof value === 'number';",
bigint: "const isBigint = (value: unknown): value is bigint => typeof value === 'bigint';",
boolean: "const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean';"
};
var specialTypePredicateMap = {
null: "const isNull = (value: unknown): value is null => value === null;",
undefined: "const isUndefined = (value: unknown): value is undefined => typeof value === 'undefined';",
any: "const isAny = (value: unknown): value is any => true;",
unknown: "const isUnknown = (value: unknown): value is unknown => true;",
never: "const isNever = (value: unknown): value is never => false;",
void: "const isVoid = (value: unknown): value is void => false;",
Date: `const isDate = (value: unknown): value is Date =>
value instanceof Date || Object.prototype.toString.call(value) === '[Object Date]'`
};
var utilTypePredicateMap = {
object: `const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);`,
array: (option) => `type ArrayCheckOption = 'all' | 'first';
const isArray = <T>(
childCheckFn:
| ((value: unknown) => value is T)
| ((value: unknown) => boolean),
checkOption: ArrayCheckOption = '${option}'
) => (array: unknown): boolean =>
Array.isArray(array) &&
(checkOption === 'all'
? ((array) => {
for (const val of array) {
if (!childCheckFn(val)) return false
}
return true;
})(array)
: typeof array[0] === "undefined" || childCheckFn(array[0]));`,
union: `const isUnion = (unionChecks: ((value: unknown) => boolean)[]) =>
(value: unknown): boolean =>
unionChecks.reduce((s: boolean, isT) => s || isT(value), false)`,
hasNotUnlistedProperties: `const hasNotUnlistedProperties = (listedKeys: string[]) =>
(value: Record<string, unknown>): boolean =>
Object.keys(value).every(key => listedKeys.includes(key))`
};
function isPossibleUseTypeName(value) {
return ["ArrayTO", "ObjectTO", "UnionTO"].includes(value.__type);
}
function generateDeclare(argName, typeName, additionalArgs = []) {
return `(${argName}: unknown${additionalArgs.length !== 0 ? ", " + additionalArgs.map(({ name, type }) => `${name}: ${type}`).join(", ") : ""}): ${typeName ? `${argName} is ${typeName}` : "boolean"} => `;
}
function isMaybeUndefined(type) {
return type.__type === "SpecialTO" && type.kind === "undefined" || type.__type === "UnionTO" && typeof type.unions.find(
(union) => union.__type === "SpecialTO" && union.kind === "undefined"
) !== "undefined";
}
function generateTypePredicates(files, asserts = false, defaultArrayCheckOption = "all", comment = false, whitelist = false) {
const usedPrimitives = [];
const usedSpecials = [];
const usedUtils = [];
const typeNames = files.flatMap(
({ types }) => types.map(({ typeName }) => typeName)
);
const generateCheckFn = ({
type,
typeName,
parentArgCount
}) => {
const argCount = parentArgCount + 1;
const argName = () => `arg_${argCount}`;
const isToplevel = typeof typeName === "string";
if (!isToplevel && isPossibleUseTypeName(type) && typeNames.includes(type.typeName)) {
return `is${type.typeName}`;
}
if (type.__type === "PrimitiveTO") {
usedPrimitives.push(type.kind);
return primitiveTypePredicateNameMap[type.kind];
} else if (type.__type === "SpecialTO") {
usedSpecials.push(type.kind);
return specialTypePredicateNameMap[type.kind];
} else if (type.__type === "LiteralTO") {
return `${generateDeclare(argName(), typeName)}${argName()} === ${typeof type.value === "string" ? '"' + type.value + '"' : type.value}`;
} else if (type.__type === "UnionTO") {
usedUtils.push("union");
return `${generateDeclare(argName(), typeName)}isUnion([${type.unions.map(
(unionType) => generateCheckFn({ type: unionType, parentArgCount: argCount })
).join(", ")}])(${argName()})`;
} else if (type.__type === "ArrayTO") {
usedUtils.push("array");
const checkChildFn = generateCheckFn({
type: type.child,
parentArgCount: argCount
});
const checkOptionArgName = "checkOpt";
return `${generateDeclare(
argName(),
typeName,
isToplevel ? [
{
name: checkOptionArgName,
type: "ArrayCheckOption = 'all'"
}
] : []
)}isArray(${checkChildFn}${isToplevel ? `, ${checkOptionArgName}` : ""})(${argName()})`;
} else if (type.__type === "ObjectTO") {
usedUtils.push("object");
if (whitelist)
usedUtils.push("hasNotUnlistedProperties");
return `${generateDeclare(
argName(),
typeName
)}isObject(${argName()}) && ${whitelist ? `hasNotUnlistedProperties([${type.getProps().map((prop) => `'${prop.propName}'`).join(", ")}])(${argName()}) &&` : ``}
${type.getProps().map(
({ propName, type: type2 }) => `(${isMaybeUndefined(type2) ? `` : `'${propName}' in ${argName()} && `}(${generateCheckFn({
type: type2,
parentArgCount: argCount
})})(${argName()}['${propName}']))`
).join(" && ")}`;
} else if (type.__type === "TypeParameterTO") {
return `(_) => true`;
} else if (type.__type === "TupleTO") {
return `${generateDeclare(
argName(),
typeName
)}Array.isArray(${argName()}) && (${type.items.map(
(item, index) => `(${generateCheckFn({
type: item,
parentArgCount: argCount
})})(${argName()}[${index}])`
).join(" && ")})`;
}
console.warn(
`An unsupported or unknown type was detected. The generated function will skip the check (TypeName: ${typeName ?? "unknown"})`
);
return `/* WARN: Not Supported Type */ (value: unknown)${typeof typeName === "string" ? `:value is ${typeName}` : ""} => {
console.warn(\`check was skipped because \${value} is not supported type.\`);
return true;
}`;
};
const generateJSDocComment = ({
type,
typeName,
isAssertion
}) => {
return isAssertion ? `/**
* Assert if a variable is of type {@link ${typeName}} and throws a TypeError if the assertion fails.
* This function is automatically generated using [type-predicates-generator](https://www.npmjs.com/package/type-predicates-generator).
* @param value Argument to inspect.
* @throw TypeError if the given argument is not compatible with the type {@link ${typeName}}.
*/
` : `/**
* Check if a variable is of type {@link ${typeName}} and narrow it down to that type if the check passes.
* This function is automatically generated using [type-predicates-generator](https://www.npmjs.com/package/type-predicates-generator).
* @param arg_0 Argument to inspect.${type.__type === "ArrayTO" ? "\n * @param checkOpt Whether to check all elements of the array or only the first one." : ""}
* @return \`true\` if the argument is of type {@link ${typeName}}, \`false\` otherwise.
*/
`;
};
const generatedTypeNames = [];
const skipImports = [];
const checkFns = files.flatMap(
(file) => file.types.map((type) => ({ ...type, importPath: file.importPath }))
).map(({ type, typeName, importPath }) => {
if (reservedNames.includes(typeName)) {
console.log(`is${typeName} is reserved word, so skip generation.`);
skipImports.push({
typeName,
importPath
});
return ``;
}
if (generatedTypeNames.includes(typeName)) {
console.warn(
`${typeName} skips generation because duplicated. If it isn't caused by re-export, there may be a problem with the predicate function that uses is${typeName}.`
);
skipImports.push({
typeName,
importPath
});
return ``;
}
if (type.__type === "UnknownTO") {
console.warn(`Unsupported type ${typeName} is skipped.`);
skipImports.push({
typeName,
importPath
});
return ``;
}
generatedTypeNames.push(typeName);
return `${comment ? generateJSDocComment({ type, typeName, isAssertion: false }) : ""}export const is${typeName} = ${generateCheckFn({
type,
typeName,
parentArgCount: -1
})};
${asserts ? `${comment ? generateJSDocComment({ type, typeName, isAssertion: true }) : ""}export function assertIs${typeName}(value: unknown): asserts value is ${typeName} {
if (!is${typeName}(value)) throw new TypeError(\`value must be ${typeName} but received \${value}\`)
};` : ""}`;
});
const corePredicates = uniq(
[
usedPrimitives.map((kind) => primitiveTypePredicateMap[kind]),
usedSpecials.map((kind) => specialTypePredicateMap[kind]),
usedUtils.map(
(name) => name === "array" ? utilTypePredicateMap[name](defaultArrayCheckOption) : utilTypePredicateMap[name]
)
].flat()
);
return `// @ts-nocheck
/* eslint-disable */
${files.filter(
({ types, importPath }) => types.length - skipImports.filter(
({ importPath: skipImportPath }) => skipImportPath === importPath
).length !== 0
).map(
({ importPath, types }) => `import type { ${types.filter(
({ typeName }) => !skipImports.find(
({ typeName: skipTypeName, importPath: skipImportPath }) => skipTypeName === typeName && skipImportPath === importPath
)
).map(({ typeName }) => typeName).join(", ")} } from '${importPath}'`
).join(";\n")};
${corePredicates.join("\n")}
${checkFns.map((checkFn) => checkFn).join("\n")}`;
}
// src/generate/index.ts
async function run({
tsconfigPath,
fileGlobs,
output,
basePath,
option
}) {
const files = fileGlobs.flatMap(
(fileGlob) => glob.sync(fileGlob, {
sync: true,
cwd: basePath,
ignore: ["**/node_modules/**/*", output]
})
).map((filePath) => resolve2(basePath, filePath)).filter((filePath) => filePath !== output);
let program;
if (option.watch) {
let onUpdate = void 0;
const watcher = watchCompiler(
tsconfigPath,
files,
() => {
if (onUpdate) {
onUpdate();
}
},
{
watchFile: WatchFileKind.UseFsEvents,
excludeFiles: [output]
},
// デフォルトのメソッドを打ち消すため
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => {
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => {
}
);
onUpdate = () => {
const updatedProgram = watcher.getProgram().getProgram();
generateAndWriteCodes(updatedProgram, files, output, option);
console.log("File changes are detected, and successfully regenerated.");
};
program = watcher.getProgram().getProgram();
console.log("start watching ...");
} else {
program = createProgram(tsconfigPath);
}
generateAndWriteCodes(program, files, output, option);
console.log(`successfully generated: ${output}`);
}
var generateAndWriteCodes = (program, files, output, { asserts, defaultArrayCheckOption, comment, whitelist }) => {
const handler = new CompilerApiHandler(program);
const types = files.flatMap((filePath) => {
const result = handler.extractTypes(filePath);
const importPath = "./" + relative(resolve2(output, ".."), filePath).replace(".d.ts", "").replace(".ts", "");
if (isNg(result)) {
console.warn(
`Failed to extract types from ${filePath} for reason ${result.ng.reason}`
);
return [];
}
return [
{
importPath,
types: result.ok.filter(
(type) => typeof type.typeName === "string"
)
}
];
});
const generatedCode = generateTypePredicates(
types,
asserts,
defaultArrayCheckOption,
comment,
whitelist
);
writeFileSync(output, generatedCode);
};
export {
run
};
//# sourceMappingURL=index.mjs.map