UNPKG

type-predicates-generator

Version:

Predicate and assert functions generator from type definitions.

798 lines (785 loc) 26.6 kB
#!/usr/bin/env node // src/cli.ts import { resolve as resolve3 } from "path"; import { Command } from "commander"; // 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, resolve4) => toResult(target, isMatch, resolve4, resolved) }; }; var switchExpression = (target) => { return { resolved: void 0, default: (resolveDefault) => resolveDefault(target), case: (isMatch, resolve4) => toResult(target, isMatch, resolve4, 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(program2) { this.#program = program2; 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: (option2) => `type ArrayCheckOption = 'all' | 'first'; const isArray = <T>( childCheckFn: | ((value: unknown) => value is T) | ((value: unknown) => boolean), checkOption: ArrayCheckOption = '${option2}' ) => (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: option2 }) { 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 program2; if (option2.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, option2); console.log("File changes are detected, and successfully regenerated."); }; program2 = watcher.getProgram().getProgram(); console.log("start watching ..."); } else { program2 = createProgram(tsconfigPath); } generateAndWriteCodes(program2, files, output, option2); console.log(`successfully generated: ${output}`); } var generateAndWriteCodes = (program2, files, output, { asserts, defaultArrayCheckOption, comment, whitelist }) => { const handler = new CompilerApiHandler(program2); 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); }; // src/cli.ts var program = new Command(); program.option( "-p, --project <type>", "Path for project tsconfig.json", "tsconfig.json" ).option("-f, --file-glob <type>", "file glob pattern target types", "**/*.ts").option("-o, --output <type>", "output file path", "type-predicates.ts").option("-b, --base-path <type>", "project base path", "./").option("-a, --asserts", "generate assert functions or not", false).option("-w, --watch", "watch or not", false).option( "--default-array-check-option", "how to check child element type. 'all' or 'first'", "all" ).option("-c, --comment", "generate JSDoc comments or not", false).option("--whitelist", "not allow non listed properties").parse(process.argv); var option = program.opts(); var cwd = process.cwd(); run({ tsconfigPath: resolve3(cwd, option.project), fileGlobs: [option.fileGlob], output: resolve3(cwd, option.output), basePath: resolve3(cwd, option.basePath), option: { asserts: option.asserts, watch: option.watch, defaultArrayCheckOption: option.defaultArrayCheckOption, comment: option.comment, whitelist: option.whitelist } }); //# sourceMappingURL=cli.mjs.map