UNPKG

@definitelytyped/dts-critic

Version:

Checks a new .d.ts against the Javascript source and tells you what problems it has

780 lines (686 loc) 27.8 kB
import yargs = require("yargs"); import headerParser = require("@definitelytyped/header-parser"); import fs = require("fs"); import path = require("path"); import ts from "typescript"; export enum ErrorKind { /** Declaration needs to use `export =` to match the JavaScript module's behavior. */ NeedsExportEquals = "NeedsExportEquals", /** Declaration has a default export, but JavaScript module does not have a default export. */ NoDefaultExport = "NoDefaultExport", /** JavaScript exports property not found in declaration exports. */ JsPropertyNotInDts = "JsPropertyNotInDts", /** Declaration exports property not found in JavaScript exports. */ DtsPropertyNotInJs = "DtsPropertyNotInJs", /** JavaScript module has signatures, but declaration module does not. */ JsSignatureNotInDts = "JsSignatureNotInDts", /** Declaration module has signatures, but JavaScript module does not. */ DtsSignatureNotInJs = "DtsSignatureNotInJs", } export interface CheckOptions { errors: Map<ExportErrorKind, boolean>; } export type ExportErrorKind = ExportError["kind"]; export function dtsCritic( dtsPath: string, sourcePath: string, options: CheckOptions = { errors: new Map() }, debug = false, ): CriticError[] { if (!sourcePath && require.main !== module) { // dtslint will issue an error. return []; } const name = findDtsName(dtsPath); const packageJsonPath = path.join(path.dirname(path.resolve(dtsPath)), "package.json"); const header = parsePackageJson(name, JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")), path.dirname(dtsPath)); if (header === undefined) { return []; } else if (header.nonNpm) { const errors: CriticError[] = []; if (sourcePath) { errors.push(...checkSource(name, dtsPath, sourcePath, options.errors, debug)); } else if (require.main === module) { console.log(`Warning: declaration provided is for a non-npm package. If you want to check the declaration against the JavaScript source code, you must provide a path to the source file.`); } return errors; } else { let sourceEntry; if (!fs.statSync(sourcePath).isDirectory()) { sourceEntry = sourcePath; } else { sourceEntry = require.resolve(path.resolve(sourcePath)); } return checkSource(name, dtsPath, sourceEntry, options.errors, debug); } } function parsePackageJson( packageName: string, packageJson: Record<string, unknown>, dirPath: string, ): headerParser.Header | undefined { const result = headerParser.validatePackageJson(packageName, packageJson, headerParser.getTypesVersions(dirPath)); if (Array.isArray(result)) console.log(result.join("\n")); return Array.isArray(result) ? undefined : result; } export const defaultErrors: ExportErrorKind[] = [ErrorKind.NeedsExportEquals, ErrorKind.NoDefaultExport]; function main() { const argv = yargs .usage( "$0 --dts path-to-d.ts --js path-to-source [--debug]\n\nIf source-folder is not provided, I will look for a matching package on npm.", ) .option("dts", { describe: "Path of declaration file to be critiqued.", type: "string", }) .demandOption("dts", "Please provide a path to a d.ts file for me to critique.") .option("js", { describe: "Path of JavaScript file to be used as source.", type: "string", }) .demandOption("js", "Please provide a path to a JavaScript file for me to compare the d.ts against.") .option("debug", { describe: "Turn debug logging on.", type: "boolean", default: false, }) .help() .parseSync(); const opts = { mode: argv.mode, errors: new Map() }; const errors = dtsCritic(argv.dts, argv.js, opts, argv.debug); if (errors.length === 0) { console.log("No errors!"); } else { for (const error of errors) { console.log("Error: " + error.message); } } } /** * If dtsName is 'index' (as with DT) then look to the parent directory for the name. */ export function findDtsName(dtsPath: string) { const resolved = path.resolve(dtsPath); const baseName = path.basename(resolved, ".d.ts"); if (baseName && baseName !== "index") { return baseName; } return path.basename(path.dirname(resolved)); } export function checkSource( name: string, dtsPath: string, srcPath: string, enabledErrors: Map<ExportErrorKind, boolean>, debug: boolean, ): ExportError[] { const diagnostics = checkExports(name, dtsPath, srcPath); if (debug) { console.log(formatDebug(name, diagnostics)); } return diagnostics.errors.filter((err) => enabledErrors.get(err.kind) ?? defaultErrors.includes(err.kind)); } function formatDebug(name: string, diagnostics: ExportsDiagnostics): string { const lines: string[] = []; lines.push(`\tDiagnostics for package ${name}.`); lines.push("\tInferred source module structure:"); if (isSuccess(diagnostics.jsExportKind)) { lines.push(diagnostics.jsExportKind.result); } else { lines.push(`Could not infer type of JavaScript exports. Reason: ${diagnostics.jsExportKind.reason}`); } lines.push("\tInferred source export type:"); if (isSuccess(diagnostics.jsExportType)) { lines.push(formatType(diagnostics.jsExportType.result)); } else { lines.push(`Could not infer type of JavaScript exports. Reason: ${diagnostics.jsExportType.reason}`); } if (diagnostics.dtsExportKind) { lines.push("\tInferred declaration module structure:"); if (isSuccess(diagnostics.dtsExportKind)) { lines.push(diagnostics.dtsExportKind.result); } else { lines.push(`Could not infer type of declaration exports. Reason: ${diagnostics.dtsExportKind.reason}`); } } if (diagnostics.dtsExportType) { lines.push("\tInferred declaration export type:"); if (isSuccess(diagnostics.dtsExportType)) { lines.push(formatType(diagnostics.dtsExportType.result)); } else { lines.push(`Could not infer type of declaration exports. Reason: ${diagnostics.dtsExportType.reason}`); } } return lines.join("\n"); } function formatType(type: ts.Type): string { const lines: string[] = []; //@ts-ignore property `checker` of `ts.Type` is marked internal. The alternative is to have a TypeChecker parameter. const checker: ts.TypeChecker = type.checker; const properties = type.getProperties(); if (properties.length > 0) { lines.push("Type's properties:"); lines.push(...properties.map((p) => p.getName())); } const signatures = type.getConstructSignatures().concat(type.getCallSignatures()); if (signatures.length > 0) { lines.push("Type's signatures:"); lines.push(...signatures.map((s) => checker.signatureToString(s))); } lines.push(`Type string: ${checker.typeToString(type)}`); return lines.join("\n"); } const exportEqualsLink = "https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require"; /** * Checks exports of a declaration file against its JavaScript source. */ function checkExports(name: string, dtsPath: string, sourcePath: string): ExportsDiagnostics { const tscOpts = { allowJs: true, }; const jsProgram = ts.createProgram([sourcePath], tscOpts); const jsFileNode = jsProgram.getSourceFile(sourcePath); if (!jsFileNode) { throw new Error(`TS compiler could not find source file ${sourcePath}.`); } const jsChecker = jsProgram.getTypeChecker(); const errors: ExportError[] = []; const sourceDiagnostics = inspectJs(jsFileNode, jsChecker, name); const dtsDiagnostics = inspectDts(dtsPath, name); if ( isSuccess(sourceDiagnostics.exportEquals) && sourceDiagnostics.exportEquals.result.judgement === ExportEqualsJudgement.Required && isSuccess(dtsDiagnostics.exportKind) && dtsDiagnostics.exportKind.result !== DtsExportKind.ExportEquals ) { const error = { kind: ErrorKind.NeedsExportEquals, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration should use 'export =' syntax because the JavaScript source uses 'module.exports =' syntax and ${sourceDiagnostics.exportEquals.result.reason}. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, } as const; errors.push(error); } const compatibility = exportTypesCompatibility( name, sourceDiagnostics.exportType, dtsDiagnostics.exportType, dtsDiagnostics.exportKind, ); if (isSuccess(compatibility)) { errors.push(...compatibility.result); } if (dtsDiagnostics.defaultExport && !sourceDiagnostics.exportsDefault) { errors.push({ kind: ErrorKind.NoDefaultExport, position: dtsDiagnostics.defaultExport, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration specifies 'export default' but the JavaScript source does not mention 'default' anywhere. The most common way to resolve this error is to use 'export =' syntax instead of 'export default'. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, }); } return { jsExportKind: sourceDiagnostics.exportKind, jsExportType: sourceDiagnostics.exportType, dtsExportKind: dtsDiagnostics.exportKind, dtsExportType: dtsDiagnostics.exportType, errors, }; } function inspectJs(sourceFile: ts.SourceFile, checker: ts.TypeChecker, packageName: string): JsExportsInfo { const exportKind = getJsExportKind(sourceFile); const exportType = getJSExportType(sourceFile, checker, exportKind); const exportsDefault = sourceExportsDefault(sourceFile, packageName); let exportEquals; if (isSuccess(exportType) && isSuccess(exportKind) && exportKind.result === JsExportKind.CommonJs) { exportEquals = moduleTypeNeedsExportEquals(exportType.result, checker); } else { exportEquals = mergeErrors(exportType, exportKind); } return { exportKind, exportType, exportEquals, exportsDefault }; } function getJsExportKind(sourceFile: ts.SourceFile): InferenceResult<JsExportKind> { // @ts-ignore property `commonJsModuleIndicator` of `ts.SourceFile` is marked internal. if (sourceFile.commonJsModuleIndicator) { return inferenceSuccess(JsExportKind.CommonJs); } // @ts-ignore property `externalModuleIndicator` of `ts.SourceFile` is marked internal. if (sourceFile.externalModuleIndicator) { return inferenceSuccess(JsExportKind.ES6); } return inferenceError("Could not infer export kind of source file."); } function getJSExportType( sourceFile: ts.SourceFile, checker: ts.TypeChecker, exportKind: InferenceResult<JsExportKind>, ): InferenceResult<ts.Type> { if (isSuccess(exportKind)) { switch (exportKind.result) { case JsExportKind.CommonJs: { checker.getSymbolAtLocation(sourceFile); // TODO: get symbol in a safer way? //@ts-ignore property `symbol` of `ts.Node` is marked internal. const fileSymbol: ts.Symbol | undefined = sourceFile.symbol; if (!fileSymbol) { return inferenceError(`TS compiler could not find symbol for file node '${sourceFile.fileName}'.`); } const exportType = checker.getTypeOfSymbolAtLocation(fileSymbol, sourceFile); return inferenceSuccess(exportType); } case JsExportKind.ES6: { const fileSymbol = checker.getSymbolAtLocation(sourceFile); if (!fileSymbol) { return inferenceError(`TS compiler could not find symbol for file node '${sourceFile.fileName}'.`); } const exportType = checker.getTypeOfSymbolAtLocation(fileSymbol, sourceFile); return inferenceSuccess(exportType); } } } return inferenceError(`Could not infer type of exports because exports kind is undefined.`); } /** * Decide if a JavaScript source module could have a default export. */ function sourceExportsDefault(sourceFile: ts.SourceFile, name: string): boolean { const src = sourceFile.getFullText(sourceFile); return ( isRealExportDefault(name) || src.indexOf("default") > -1 || src.indexOf("__esModule") > -1 || src.indexOf("react-side-effect") > -1 || src.indexOf("@flow") > -1 || src.indexOf("module.exports = require") > -1 ); } function moduleTypeNeedsExportEquals(type: ts.Type, checker: ts.TypeChecker): InferenceResult<ExportEqualsDiagnostics> { if (isBadType(type)) { return inferenceError(`Inferred type '${checker.typeToString(type)}' is not good enough to be analyzed.`); } const isObject = type.getFlags() & ts.TypeFlags.Object; // @ts-ignore property `isArrayLikeType` of `ts.TypeChecker` is marked internal. if (isObject && !hasSignatures(type) && !checker.isArrayLikeType(type)) { const judgement = ExportEqualsJudgement.NotRequired; const reason = "'module.exports' is an object which is neither a function, class, or array"; return inferenceSuccess({ judgement, reason }); } if (hasSignatures(type)) { const judgement = ExportEqualsJudgement.Required; const reason = "'module.exports' can be called or constructed"; return inferenceSuccess({ judgement, reason }); } const primitive = ts.TypeFlags.Boolean | ts.TypeFlags.String | ts.TypeFlags.Number; if (type.getFlags() & primitive) { const judgement = ExportEqualsJudgement.Required; const reason = `'module.exports' has primitive type ${checker.typeToString(type)}`; return inferenceSuccess({ judgement, reason }); } // @ts-ignore property `isArrayLikeType` of `ts.TypeChecker` is marked internal. if (checker.isArrayLikeType(type)) { const judgement = ExportEqualsJudgement.Required; const reason = `'module.exports' has array-like type ${checker.typeToString(type)}`; return inferenceSuccess({ judgement, reason }); } return inferenceError(`Could not analyze type '${checker.typeToString(type)}'.`); } function hasSignatures(type: ts.Type): boolean { return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; } function inspectDts(dtsPath: string, name: string): DtsExportDiagnostics { dtsPath = path.resolve(dtsPath); const program = createDtProgram(dtsPath); const sourceFile = program.getSourceFile(path.resolve(dtsPath)); if (!sourceFile) { throw new Error(`TS compiler could not find source file '${dtsPath}'.`); } const checker = program.getTypeChecker(); const symbolResult = getDtsModuleSymbol(sourceFile, checker, name); const exportKindResult = getDtsExportKind(sourceFile); const exportType = getDtsExportType(sourceFile, checker, symbolResult, exportKindResult); const defaultExport = getDtsDefaultExport(sourceFile, exportType); return { exportKind: exportKindResult, exportType, defaultExport }; } function createDtProgram(dtsPath: string): ts.Program { const dtsDir = path.dirname(dtsPath); const configPath = path.join(dtsDir, "tsconfig.json"); const { config } = ts.readConfigFile(configPath, (p) => fs.readFileSync(p, { encoding: "utf8" })); const parseConfigHost: ts.ParseConfigHost = { fileExists: fs.existsSync, readDirectory: ts.sys.readDirectory, readFile: (file) => fs.readFileSync(file, { encoding: "utf8" }), useCaseSensitiveFileNames: true, }; const parsed = ts.parseJsonConfigFileContent(config, parseConfigHost, path.resolve(dtsDir)); const host = ts.createCompilerHost(parsed.options, true); return ts.createProgram([path.resolve(dtsPath)], parsed.options, host); } function getDtsModuleSymbol( sourceFile: ts.SourceFile, checker: ts.TypeChecker, name: string, ): InferenceResult<ts.Symbol> { if (matches(sourceFile, (node) => ts.isModuleDeclaration(node))) { const npmName = dtToNpmName(name); const moduleSymbol = checker.getAmbientModules().find((symbol) => symbol.getName() === `"${npmName}"`); if (moduleSymbol) { return inferenceSuccess(moduleSymbol); } } const fileSymbol = checker.getSymbolAtLocation(sourceFile); if (fileSymbol && fileSymbol.getFlags() & ts.SymbolFlags.ValueModule) { return inferenceSuccess(fileSymbol); } return inferenceError(`Could not find module symbol for source file node.`); } function getDtsExportKind(sourceFile: ts.SourceFile): InferenceResult<DtsExportKind> { if (matches(sourceFile, isExportEquals)) { return inferenceSuccess(DtsExportKind.ExportEquals); } if (matches(sourceFile, isExportConstruct)) { return inferenceSuccess(DtsExportKind.ES6Like); } return inferenceError("Could not infer export kind of declaration file."); } const exportEqualsSymbolName = "export="; function getDtsExportType( sourceFile: ts.SourceFile, checker: ts.TypeChecker, symbolResult: InferenceResult<ts.Symbol>, exportKindResult: InferenceResult<DtsExportKind>, ): InferenceResult<ts.Type> { if (isSuccess(symbolResult) && isSuccess(exportKindResult)) { const symbol = symbolResult.result; const exportKind = exportKindResult.result; switch (exportKind) { case DtsExportKind.ExportEquals: { const exportSymbol = symbol.exports!.get(exportEqualsSymbolName as ts.__String); if (!exportSymbol) { return inferenceError(`TS compiler could not find \`export=\` symbol.`); } const exportType = checker.getTypeOfSymbolAtLocation(exportSymbol, sourceFile); return inferenceSuccess(exportType); } case DtsExportKind.ES6Like: { const exportType = checker.getTypeOfSymbolAtLocation(symbol, sourceFile); return inferenceSuccess(exportType); } } } return mergeErrors(symbolResult, exportKindResult); } /** * Returns the position of the default export, if it exists. */ function getDtsDefaultExport(sourceFile: ts.SourceFile, moduleType: InferenceResult<ts.Type>): Position | undefined { if (isError(moduleType)) { const src = sourceFile.getFullText(sourceFile); const exportDefault = src.indexOf("export default"); if (exportDefault > -1 && src.indexOf("export =") === -1 && !/declare module ['"]/.test(src)) { return { start: exportDefault, length: "export default".length, }; } return undefined; } const exportDefault = moduleType.result.getProperty("default"); if (exportDefault?.declarations) { return { start: exportDefault.declarations[0].getStart(), length: exportDefault.declarations[0].getWidth(), }; } return undefined; } const ignoredProperties = ["__esModule", "prototype", "default", "F", "G", "S", "P", "B", "W", "U", "R"]; function ignoreProperty(property: ts.Symbol): boolean { const name = property.getName(); return name.startsWith("_") || ignoredProperties.includes(name); } /* * Given the inferred type of the exports of both source and declaration, we make the following checks: * 1. If source type has call or construct signatures, then declaration type should also have call or construct signatures. * 2. If declaration type has call or construct signatures, then source type should also have call or construct signatures. * 3. If source type has a property named "foo", then declaration type should also have a property named "foo". * 4. If declaration type has a property named "foo", then source type should also have a property named "foo". * Checks (2) and (4) don't work well in practice and should not be used for linting/verification purposes, because * most of the times the error originates because the inferred type of the JavaScript source has missing information. * Those checks are useful for finding examples where JavaScript type inference could be improved. */ function exportTypesCompatibility( name: string, sourceType: InferenceResult<ts.Type>, dtsType: InferenceResult<ts.Type>, dtsExportKind: InferenceResult<DtsExportKind>, ): InferenceResult<MissingExport[]> { if (isError(sourceType)) { return inferenceError("Could not get type of exports of source module."); } if (isError(dtsType)) { return inferenceError("Could not get type of exports of declaration module."); } if (isBadType(sourceType.result)) { return inferenceError("Could not infer meaningful type of exports of source module."); } if (isBadType(dtsType.result)) { return inferenceError("Could not infer meaningful type of exports of declaration module."); } const errors: MissingExport[] = []; if (hasSignatures(sourceType.result) && !hasSignatures(dtsType.result)) { if (isSuccess(dtsExportKind) && dtsExportKind.result === DtsExportKind.ExportEquals) { errors.push({ kind: ErrorKind.JsSignatureNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module can be called or constructed, but the declaration module cannot.`, }); } else { errors.push({ kind: ErrorKind.JsSignatureNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module can be called or constructed, but the declaration module cannot. The most common way to resolve this error is to use 'export =' syntax. To learn more about 'export =' syntax, see ${exportEqualsLink}.`, }); } } if (hasSignatures(dtsType.result) && !hasSignatures(sourceType.result)) { errors.push({ kind: ErrorKind.DtsSignatureNotInJs, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration module can be called or constructed, but the JavaScript module cannot.`, }); } const sourceProperties = sourceType.result.getProperties(); const dtsProperties = dtsType.result.getProperties(); for (const sourceProperty of sourceProperties) { // TODO: check `prototype` properties. if (ignoreProperty(sourceProperty)) continue; if (!dtsProperties.find((s) => s.getName() === sourceProperty.getName())) { errors.push({ kind: ErrorKind.JsPropertyNotInDts, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The JavaScript module exports a property named '${sourceProperty.getName()}', which is missing from the declaration module.`, }); } } for (const dtsProperty of dtsProperties) { // TODO: check `prototype` properties. if (ignoreProperty(dtsProperty)) continue; if (!sourceProperties.find((s) => s.getName() === dtsProperty.getName())) { const error: MissingExport = { kind: ErrorKind.DtsPropertyNotInJs, message: `The declaration doesn't match the JavaScript module '${name}'. Reason: The declaration module exports a property named '${dtsProperty.getName()}', which is missing from the JavaScript module.`, }; const declaration = dtsProperty.declarations && dtsProperty.declarations.length > 0 ? dtsProperty.declarations[0] : undefined; if (declaration) { error.position = { start: declaration.getStart(), length: declaration.getWidth(), }; } errors.push(error); } } return inferenceSuccess(errors); } function isBadType(type: ts.Type): boolean { return !!(type.getFlags() & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Undefined | ts.TypeFlags.Null)); } function isExportEquals(node: ts.Node): boolean { return ts.isExportAssignment(node) && !!node.isExportEquals; } function isExportConstruct(node: ts.Node): boolean { return ts.isExportAssignment(node) || ts.isExportDeclaration(node) || hasExportModifier(node); } function hasExportModifier(node: ts.Node): boolean { if (ts.canHaveModifiers(node)) { return !!ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); } return false; } function matches(srcFile: ts.SourceFile, predicate: (n: ts.Node) => boolean): boolean { function matchesNode(node: ts.Node): boolean { if (predicate(node)) return true; const children = node.getChildren(srcFile); for (const child of children) { if (matchesNode(child)) return true; } return false; } return matchesNode(srcFile); } function isRealExportDefault(name: string) { return name.indexOf("react-native") > -1 || name === "ember-feature-flags" || name === "material-ui-datatables"; } /** * Converts a package name from the name used in DT repository to the name used in npm. * @param baseName DT name of a package */ export function dtToNpmName(baseName: string) { if (/__/.test(baseName)) { return "@" + baseName.replace("__", "/"); } return baseName; } /** * @param error case-insensitive name of the error */ export function parseExportErrorKind(error: string): ExportErrorKind | undefined { error = error.toLowerCase(); switch (error) { case "needsexportequals": return ErrorKind.NeedsExportEquals; case "nodefaultexport": return ErrorKind.NoDefaultExport; case "jspropertynotindts": return ErrorKind.JsPropertyNotInDts; case "dtspropertynotinjs": return ErrorKind.DtsPropertyNotInJs; case "jssignaturenotindts": return ErrorKind.JsSignatureNotInDts; case "dtssignaturenotinjs": return ErrorKind.DtsSignatureNotInJs; } return undefined; } export interface CriticError { kind: ErrorKind; message: string; position?: Position; } interface ExportEqualsError extends CriticError { kind: ErrorKind.NeedsExportEquals; } interface DefaultExportError extends CriticError { kind: ErrorKind.NoDefaultExport; position: Position; } interface MissingExport extends CriticError { kind: | ErrorKind.JsPropertyNotInDts | ErrorKind.DtsPropertyNotInJs | ErrorKind.JsSignatureNotInDts | ErrorKind.DtsSignatureNotInJs; } interface Position { start: number; length: number; } interface ExportsDiagnostics { jsExportKind: InferenceResult<JsExportKind>; jsExportType: InferenceResult<ts.Type>; dtsExportKind: InferenceResult<DtsExportKind>; dtsExportType: InferenceResult<ts.Type>; errors: ExportError[]; } type ExportError = ExportEqualsError | DefaultExportError | MissingExport; interface JsExportsInfo { exportKind: InferenceResult<JsExportKind>; exportType: InferenceResult<ts.Type>; exportEquals: InferenceResult<ExportEqualsDiagnostics>; exportsDefault: boolean; } enum JsExportKind { CommonJs = "CommonJs", ES6 = "ES6", } interface ExportEqualsDiagnostics { judgement: ExportEqualsJudgement; reason: string; } enum ExportEqualsJudgement { Required = "Required", NotRequired = "Not required", } enum DtsExportKind { ExportEquals = "export =", ES6Like = "ES6-like", } interface DtsExportDiagnostics { exportKind: InferenceResult<DtsExportKind>; exportType: InferenceResult<ts.Type>; defaultExport?: Position; } type InferenceResult<T> = InferenceError | InferenceSuccess<T>; enum InferenceResultKind { Error, Success, } interface InferenceError { kind: InferenceResultKind.Error; reason?: string; } interface InferenceSuccess<T> { kind: InferenceResultKind.Success; result: T; } function inferenceError(reason?: string): InferenceError { return { kind: InferenceResultKind.Error, reason }; } function inferenceSuccess<T>(result: T): InferenceSuccess<T> { return { kind: InferenceResultKind.Success, result }; } function isSuccess<T>(inference: InferenceResult<T>): inference is InferenceSuccess<T> { return inference.kind === InferenceResultKind.Success; } function isError<T>(inference: InferenceResult<T>): inference is InferenceError { return inference.kind === InferenceResultKind.Error; } function mergeErrors(...results: (InferenceResult<unknown> | string)[]): InferenceError { const reasons: string[] = []; for (const result of results) { if (typeof result === "string") { reasons.push(result); } else if (isError(result) && result.reason) { reasons.push(result.reason); } } return inferenceError(reasons.join(" ")); } if (require.main === module) { main(); }