import * as ts from 'typescript'; // Symbol name for the Throws type brand const THROWS_BRAND = '__throws'; interface CheckResult { sourceFile: ts.SourceFile; line: number; column: number; start: number; length: number; functionName: string; unhandledErrors: string[]; message: string; } export function checkSourceFile(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CheckResult[] { const results: CheckResult[] = []; function visit(node: ts.Node): void { // Check regular call expressions if (ts.isCallExpression(node)) { const result = checkCallExpression(node, sourceFile, checker); if (result) { results.push(result); } // Also check if this is a Promise.reject() call const rejectResult = checkPromiseReject(node, sourceFile, checker); if (rejectResult) { results.push(rejectResult); } // Check if this is a reject() call from Promise constructor const rejectParamResult = checkRejectParameterCall(node, sourceFile, checker); if (rejectParamResult) { results.push(rejectParamResult); } } // Check await expressions if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) { const result = checkCallExpression(node.expression, sourceFile, checker); if (result) { results.push(result); } } // Check throw statements if (ts.isThrowStatement(node)) { const result = checkThrowStatement(node, sourceFile, checker); if (result) { results.push(result); } } // Check return statements for Promise> propagation if (ts.isReturnStatement(node) && node.expression) { const result = checkReturnStatement(node, sourceFile, checker); if (result) { results.push(result); } } ts.forEachChild(node, visit); } visit(sourceFile); return results; } function preceededByIgnoreComment(node: ts.Node, sourceFile: ts.SourceFile) { const foundComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos); if (foundComments) { const foundCommentsText = foundComments.map((info) => { return sourceFile.text .slice(info.pos, info.end) .replace(/^(\/\/|\/\*)\s*/ /* Remove leading comment prefix */, ''); }); const isIgnoreComment = foundCommentsText.find((commentText) => { return commentText.startsWith('@throws-transformer ignore'); }); return isIgnoreComment; } else { return false; } } function checkThrowStatement( node: ts.ThrowStatement, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): CheckResult | null { const containingFunction = getContainingFunction(node); if (!containingFunction) { return null; } // Check if handled by local catch if (isHandledByLocalCatch(node, containingFunction)) { return null; } // Get declared error types const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); if (declaredErrors === null) { return null; // Not using Throws<> } if (!node.expression) { return null; } // Check to see if there is a comment about the throw site starting with "@throws-transformer // ignore", and if so, disregard. if (preceededByIgnoreComment(node, sourceFile)) { return null; } const thrownType = checker.getTypeAtLocation(node.expression); const thrownTypeName = checker.typeToString(thrownType); const isAllowed = isErrorTypeDeclared(checker, thrownType, declaredErrors); if (!isAllowed) { const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); const declaredNames = declaredErrors.map((t) => checker.typeToString(t)).join(' | '); return { sourceFile, line: line + 1, column: character + 1, start, length, functionName: '', unhandledErrors: [thrownTypeName], message: `Throwing '${thrownTypeName}' but it's not declared. Declared: ${declaredNames || 'never'}. Add it to Throws<> or catch internally.`, }; } return null; } function checkPromiseReject( node: ts.CallExpression, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): CheckResult | null { // Check if this is a Promise.reject() call if (!isPromiseRejectCall(node)) { return null; } const containingFunction = getContainingFunction(node); if (!containingFunction) { return null; } // Check if handled by local catch (or .catch() on the promise) const tryCatch = getContainingTryCatch(node); if (tryCatch) { return null; // Handled by try-catch } // Get declared error types from the function's return type const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); if (declaredErrors === null) { return null; // Not using Throws<> } // Get the type of the rejected value if (node.arguments.length === 0) { return null; // Promise.reject() with no argument } const rejectedType = checker.getTypeAtLocation(node.arguments[0]); const rejectedTypeName = checker.typeToString(rejectedType); const isAllowed = isErrorTypeDeclared(checker, rejectedType, declaredErrors); if (!isAllowed) { const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); const declaredNames = declaredErrors.map((t) => checker.typeToString(t)).join(' | '); return { sourceFile, line: line + 1, column: character + 1, start, length, functionName: 'Promise.reject', unhandledErrors: [rejectedTypeName], message: `Promise.reject('${rejectedTypeName}') but it's not declared. Declared: ${declaredNames || 'never'}. Add it to Throws<> in your return type.`, }; } return null; } function checkRejectParameterCall( node: ts.CallExpression, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): CheckResult | null { // Check if this is a reject() call (not Promise.reject()) if (!ts.isIdentifier(node.expression) || node.expression.text !== 'reject') { return null; } // Check if reject is a parameter from a Promise constructor const promiseConstructor = getPromiseConstructorForReject(node.expression, checker); if (!promiseConstructor) { return null; } // Get the function that contains the Promise constructor (not the executor function) const containingFunction = getContainingFunction(promiseConstructor); if (!containingFunction) { return null; } // Check if handled by local catch const tryCatch = getContainingTryCatch(promiseConstructor); if (tryCatch) { return null; // Handled by try-catch } // Get declared error types from the function's return type const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); if (declaredErrors === null) { return null; // Not using Throws<> } // Get the type of the rejected value if (node.arguments.length === 0) { return null; // reject() with no argument } const rejectedType = checker.getTypeAtLocation(node.arguments[0]); const rejectedTypeName = checker.typeToString(rejectedType); const isAllowed = isErrorTypeDeclared(checker, rejectedType, declaredErrors); if (!isAllowed) { const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); const declaredNames = declaredErrors.map((t) => checker.typeToString(t)).join(' | '); return { sourceFile, line: line + 1, column: character + 1, start, length, functionName: 'reject', unhandledErrors: [rejectedTypeName], message: `reject('${rejectedTypeName}') in Promise constructor but it's not declared. Declared: ${declaredNames || 'never'}. Add it to Throws<> in your return type.`, }; } return null; } function getPromiseConstructorForReject( identifier: ts.Identifier, checker: ts.TypeChecker, ): ts.NewExpression | null { // Get the symbol for the identifier const symbol = checker.getSymbolAtLocation(identifier); if (!symbol) { return null; } // Check if it's a parameter const declarations = symbol.getDeclarations(); if (!declarations || declarations.length === 0) { return null; } const declaration = declarations[0]; if (!ts.isParameter(declaration)) { return null; } // Check if the parameter is from a function that's passed to Promise constructor const paramFunction = declaration.parent; if (!ts.isFunctionExpression(paramFunction) && !ts.isArrowFunction(paramFunction)) { return null; } // Check if this function is an argument to a Promise constructor const parent = paramFunction.parent; // Handle both direct argument and parenthesized expressions let newExpr: ts.Node | undefined = parent; while (newExpr && ts.isParenthesizedExpression(newExpr)) { newExpr = newExpr.parent; } if (!newExpr || !ts.isNewExpression(newExpr)) { return null; } // Check if it's a new Promise(...) call const type = checker.getTypeAtLocation(newExpr.expression); const typeSymbol = type.getSymbol(); if (typeSymbol?.getName() === 'Promise' || typeSymbol?.getName() === 'PromiseConstructor') { return newExpr as ts.NewExpression; } return null; } function checkReturnStatement( node: ts.ReturnStatement, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): CheckResult | null { if (!node.expression) { return null; } const containingFunction = getContainingFunction(node); if (!containingFunction) { return null; } // Check if this is a .catch() or .then() call on a promise let returnedErrors: ts.Type[] = []; if ( ts.isCallExpression(node.expression) && ts.isPropertyAccessExpression(node.expression.expression) ) { const methodName = node.expression.expression.name.text; if (methodName === 'catch') { // For .catch(), check if it handles all errors or rethrows some returnedErrors = extractErrorsFromPromiseCatch(node.expression, checker); } else if (methodName === 'then') { // For .then(), errors propagate from the original promise const promiseExpr = node.expression.expression.expression; const promiseType = checker.getTypeAtLocation(promiseExpr); returnedErrors = extractThrowsErrorTypes(promiseType, checker); } else { // Regular method call, extract errors normally const returnedType = checker.getTypeAtLocation(node.expression); returnedErrors = extractThrowsErrorTypes(returnedType, checker); } } else { // Get the type of the returned expression const returnedType = checker.getTypeAtLocation(node.expression); // Extract error types from the returned value returnedErrors = extractThrowsErrorTypes(returnedType, checker); } if (returnedErrors.length === 0) { return null; // No errors in returned value } // If the return is inside a try block with a catch clause, errors from the // returned expression will be caught at runtime — they don't propagate through // the return. Error propagation from the catch is already checked separately // by checkThrowStatement / checkCallExpression. const tryCatch = getContainingTryCatch(node); if (tryCatch?.catchClause) { return null; } // Get declared error types from the function's return type const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); if (declaredErrors === null) { return null; // Not using Throws<> } // Find errors that are in the returned value but not declared const unhandledErrors = returnedErrors.filter((errorType) => { return !isErrorTypeDeclared(checker, errorType, declaredErrors); }); if (unhandledErrors.length === 0) { return null; } const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); const errorNames = unhandledErrors.map((e) => checker.typeToString(e)); const declaredNames = declaredErrors.map((t) => checker.typeToString(t)).join(' | '); return { sourceFile, line: line + 1, column: character + 1, start, length, functionName: '', unhandledErrors: errorNames, message: `Returning value with errors [${errorNames.join(' | ')}] but only [${declaredNames || 'never'}] declared. Add missing errors to your function's Throws<> return type.`, }; } function extractErrorsFromPromiseCatch( catchCall: ts.CallExpression, checker: ts.TypeChecker, ): ts.Type[] { // Get errors from the original promise (before .catch()) const promiseExpr = (catchCall.expression as ts.PropertyAccessExpression).expression; const promiseType = checker.getTypeAtLocation(promiseExpr); const originalErrors = extractThrowsErrorTypes(promiseType, checker); if (originalErrors.length === 0) { return []; } // Check the catch handler if (catchCall.arguments.length === 0) { return originalErrors; // No handler, errors propagate } const handler = catchCall.arguments[0]; // If it's a function, check if it rethrows if (ts.isFunctionExpression(handler) || ts.isArrowFunction(handler)) { if (!handler.body) { return []; // No body means it silences errors } // Check if the body contains a throw statement if (ts.isBlock(handler.body)) { if (containsThrowStatement(handler.body)) { // Handler rethrows - find what it throws const thrownErrors: ts.Type[] = []; function visitThrow(node: ts.Node): void { if (ts.isThrowStatement(node) && node.expression) { const thrownType = checker.getTypeAtLocation(node.expression); if (!isAnyOrUnknownType(thrownType)) { thrownErrors.push(thrownType); } } ts.forEachChild(node, visitThrow); } visitThrow(handler.body); // Return the new errors thrown by the handler return thrownErrors.length > 0 ? thrownErrors : originalErrors; } else { // Handler doesn't rethrow, errors are silenced return []; } } else { // Expression body (arrow function), doesn't throw return []; } } // If it's not a function expression, be conservative and assume errors propagate return originalErrors; } function isHandledByLocalCatch( throwNode: ts.ThrowStatement, containingFunction: ts.FunctionLikeDeclaration, ): boolean { let current: ts.Node | undefined = throwNode.parent; while (current && current !== containingFunction) { if (ts.isTryStatement(current)) { if (isInTryBlock(throwNode, current)) { // Check if catch doesn't re-throw (catch-all) const catchClause = current.catchClause; if (catchClause && !containsThrowStatement(catchClause.block)) { return true; } } } current = current.parent; } return false; } function getDeclaredErrorTypes( func: ts.FunctionLikeDeclaration, checker: ts.TypeChecker, ): ts.Type[] | null { if (!func.type) { return null; } const returnType = checker.getTypeFromTypeNode(func.type); const throwsProperty = returnType.getProperty(THROWS_BRAND); if (!throwsProperty) { // Check Promise> const promiseType = extractPromiseType(returnType, checker); if (promiseType) { const innerThrows = promiseType.getProperty(THROWS_BRAND); if (!innerThrows) { // When T is a primitive (e.g. Promise>), TypeScript distributes // the inner type into a union: `string | (string & { __throws?: E })`. if (promiseType.isUnion()) { const memberErrors = promiseType.types.flatMap((t) => extractThrowsErrorTypes(t, checker), ); if (memberErrors.length > 0) { return memberErrors; } } return null; } return extractThrowsErrorTypes(promiseType, checker); } // When T is a primitive (e.g. Throws), TypeScript distributes the // intersection into a union: `string | (string & { __throws?: E })`. The // top-level union won't have __throws, but one of its members will. if (returnType.isUnion()) { const memberErrors = returnType.types.flatMap((t) => extractThrowsErrorTypes(t, checker)); if (memberErrors.length > 0) { return memberErrors; } } return null; } return extractThrowsErrorTypes(returnType, checker); } function checkCallExpression( node: ts.CallExpression, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): CheckResult | null { // Check if this IS a .catch() call with a handler that silences errors if (isCatchCallThatSilencesErrors(node)) { return null; } // Check if this is a promise-returning call that's being chained or not immediately consumed // In these cases, the error is contained in the promise and will be handled later if (isPromiseCallNotImmediatelyConsumed(node, checker)) { return null; } // Get the return type of the call const callType = checker.getTypeAtLocation(node); const tryCatch = getContainingTryCatch(node); // Extract error types const errorTypes = tryCatch ? getTryCatchThrownErrors(tryCatch, sourceFile, checker) : extractThrowsErrorTypes(callType, checker); if (errorTypes.length === 0) { return null; } // Check handling const containingFunction = getContainingFunction(node); const handledErrors = tryCatch ? getHandledErrorTypes(tryCatch, checker, node) : new Set(); // If the catch clause contains no throws all errors are being silenced // ie, something like `try { /* code here */ } catch (err) {}` // TODO: maybe log a warning here, this is probably bad at least in some cases? if (handledErrors === 'all') { return null; } const propagatedErrorTypes = containingFunction ? getPropagatedErrorTypes(node, containingFunction, sourceFile, checker) : []; // Find unhandled - use type compatibility checking for propagated errors const unhandledErrors = errorTypes.filter((errorType) => { const errorName = checker.typeToString(errorType); // Check if handled by catch if (handledErrors.has(errorName)) { return false; } // Check if propagated using generic type compatibility if (isErrorTypeDeclared(checker, errorType, propagatedErrorTypes)) { return false; } return true; }); if (unhandledErrors.length === 0) { return null; } if (preceededByIgnoreComment(node, sourceFile)) { return null; } const start = node.getStart(); const length = node.getWidth(); const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); const functionName = getFunctionName(node); const errorNames = unhandledErrors.map((e) => checker.typeToString(e)); return { sourceFile, line: line + 1, column: character + 1, start, length, functionName, unhandledErrors: errorNames, message: `Unhandled errors from '${functionName}': ${errorNames.join(' | ')}. ` + `Catch these errors or add 'Throws<..., ${errorNames.join(' | ')}>' to your return type.`, }; } function isPromiseCallNotImmediatelyConsumed( node: ts.CallExpression, checker: ts.TypeChecker, ): boolean { // Check if this call returns a Promise const returnType = checker.getTypeAtLocation(node); const promiseType = extractPromiseType(returnType, checker); // If it doesn't return a promise, we should check it if (!promiseType) { return false; } // Check the parent context const parent = node.parent; // If it's being chained (e.g., someCall().then(...)) if (ts.isPropertyAccessExpression(parent)) { const methodName = parent.name.text; const promiseMethods = ['then', 'catch', 'finally']; if (promiseMethods.includes(methodName)) { return true; } } // If it's in a variable declaration (e.g., const x = someCall()) if (ts.isVariableDeclaration(parent)) { return true; } // If it's in a return statement, we SHOULD check it (it's being consumed) if (ts.isReturnStatement(parent)) { return false; } // If it's being awaited, we SHOULD check it (it's being consumed) if (ts.isAwaitExpression(parent)) { return false; } // Default: if returning a promise and not explicitly consumed, skip checking // This handles cases like assignment, being passed as an argument, etc. return true; } function isCatchCallThatSilencesErrors(node: ts.CallExpression): boolean { // Check if this is a .catch() method call if (!ts.isPropertyAccessExpression(node.expression)) { return false; } if (node.expression.name.text !== 'catch') { return false; } // Check if the catch handler silences errors if (node.arguments.length === 0) { return false; // No handler } const handler = node.arguments[0]; if (ts.isFunctionExpression(handler) || ts.isArrowFunction(handler)) { if (!handler.body) { return true; // No body means it silences } if (ts.isBlock(handler.body)) { // Check if it contains a throw statement return !containsThrowStatement(handler.body); } else { // Expression body doesn't throw return true; } } return false; } function extractThrowsErrorTypes(type: ts.Type, checker: ts.TypeChecker): ts.Type[] { const errors: ts.Type[] = []; // Check for __throws property const throwsProperty = type.getProperty(THROWS_BRAND); if (!throwsProperty) { // Check Promise> const promiseType = extractPromiseType(type, checker); if (promiseType) { return extractThrowsErrorTypes(promiseType, checker); } if (!promiseType && type.isUnion()) { // Check for union with null / undefined return type.types.flatMap((t) => extractThrowsErrorTypes(t, checker)); } return []; } const declaration = throwsProperty.valueDeclaration; if (!declaration) { return []; } const throwsType = checker.getTypeOfSymbolAtLocation(throwsProperty, declaration); // __throws is a method signature `__throws?(error: E): void` for bivariant // checking. The resolved type is `((error: E) => void) | undefined` because // the method is optional. Find the function member and extract E from its // first parameter. const fnType = throwsType.isUnion() ? throwsType.types.find((t) => t.getCallSignatures().length > 0) : throwsType.getCallSignatures().length > 0 ? throwsType : undefined; if (fnType) { const params = fnType.getCallSignatures()[0].getParameters(); if (params.length > 0) { const paramType = checker.getTypeOfSymbolAtLocation(params[0], declaration); if (paramType.isUnion()) { for (const t of paramType.types) { if (!isUndefinedType(t) && !isNeverType(t)) { errors.push(t); } } } else if (!isUndefinedType(paramType) && !isNeverType(paramType)) { errors.push(paramType); } } return errors; } // Fallback: property brand (readonly __throws?: E) if (throwsType.isUnion()) { for (const t of throwsType.types) { if (!isUndefinedType(t) && !isNeverType(t)) { errors.push(t); } } } else if (!isUndefinedType(throwsType) && !isNeverType(throwsType)) { errors.push(throwsType); } return errors; } function extractPromiseType(type: ts.Type, checker: ts.TypeChecker): ts.Type | null { const symbol = type.getSymbol(); if (symbol?.getName() === 'Promise') { const typeRef = type as ts.TypeReference; if (typeRef.typeArguments?.length === 1) { return typeRef.typeArguments[0]; } return null; } // Check if the type extends Promise (e.g. TypedPromise extends Promise>) // Uses TypeScript's own promise resolution to get the resolved inner type. const promisedType = (checker as any).getPromisedTypeOfPromise?.(type) as ts.Type | undefined; if (promisedType) { return promisedType; } return null; } function isErrorTypeDeclared( checker: ts.TypeChecker, thrownType: ts.Type, declaredTypes: ts.Type[], ): boolean { // Always allow throwing unknown / any (ie, rethrowing errors) if (isAnyOrUnknownType(thrownType)) { return true; } const thrownName = checker.typeToString(thrownType); for (const declared of declaredTypes) { const declaredName = checker.typeToString(declared); // Exact name match if (thrownName === declaredName) { return true; } // Check if thrown type extends the declared type const baseTypes = getBaseTypes(checker, thrownType); if (baseTypes.some((base) => checker.typeToString(base) === declaredName)) { return true; } // Check if types share the same base generic type with compatible type arguments if (areGenericTypesCompatible(checker, thrownType, declared)) { return true; } } return false; } function areGenericTypesCompatible( checker: ts.TypeChecker, source: ts.Type, target: ts.Type, ): boolean { // Cast to TypeReference to access type arguments const sourceRef = source as ts.TypeReference; const targetRef = target as ts.TypeReference; // Both must be type references with type arguments if (!sourceRef.typeArguments || !targetRef.typeArguments) { return false; } // Check if they have the same base symbol (e.g., both are TypedError<...>) const sourceSymbol = source.getSymbol(); const targetSymbol = target.getSymbol(); if (!sourceSymbol || !targetSymbol || sourceSymbol !== targetSymbol) { return false; } // Both have the same generic base, now check if type arguments are compatible if (sourceRef.typeArguments.length !== targetRef.typeArguments.length) { return false; } for (let i = 0; i < sourceRef.typeArguments.length; i++) { const sourceArg = sourceRef.typeArguments[i]; const targetArg = targetRef.typeArguments[i]; if (!isTypeArgumentAssignable(checker, sourceArg, targetArg)) { return false; } } return true; } function isTypeArgumentAssignable( checker: ts.TypeChecker, source: ts.Type, target: ts.Type, ): boolean { const sourceName = checker.typeToString(source); const targetName = checker.typeToString(target); // Exact match if (sourceName === targetName) { return true; } // Check if target is a union and source is one of its members if (target.isUnion() && target.types.length > 1) { return target.types.some((t) => { const tName = checker.typeToString(t); return tName === sourceName; }); } // Check if source is an enum member and target is the enum // e.g., source is "Types.Foo" and target is "Types" if (sourceName.includes('.') && sourceName.startsWith(targetName + '.')) { return true; } return false; } function getBaseTypes(checker: ts.TypeChecker, type: ts.Type): ts.Type[] { const bases: ts.Type[] = []; const symbol = type.getSymbol(); if (!symbol) { return bases; } const declarations = symbol.getDeclarations(); if (!declarations) { return bases; } for (const decl of declarations) { if (ts.isClassDeclaration(decl) && decl.heritageClauses) { for (const heritage of decl.heritageClauses) { if (heritage.token === ts.SyntaxKind.ExtendsKeyword) { for (const typeNode of heritage.types) { const baseType = checker.getTypeAtLocation(typeNode); bases.push(baseType); bases.push(...getBaseTypes(checker, baseType)); } } } } } return bases; } function isNeverType(type: ts.Type): boolean { return (type.flags & ts.TypeFlags.Never) !== 0; } function isUndefinedType(type: ts.Type): boolean { return (type.flags & ts.TypeFlags.Undefined) !== 0; } function getContainingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { let current: ts.Node | undefined = node.parent; while (current) { if ( ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current) || ts.isMethodDeclaration(current) ) { return current; } current = current.parent; } return null; } function getContainingTryCatch(node: ts.Node): ts.TryStatement | null { let current: ts.Node | undefined = node.parent; while (current) { if (ts.isTryStatement(current) && isInTryBlock(node, current)) { return current; } if ( ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current) || ts.isMethodDeclaration(current) ) { break; } current = current.parent; } return null; } /** Get errors which the given try/catch passed itself throws within its catch block. */ function getTryCatchThrownErrors( tryCatch: ts.TryStatement, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ) { const thrownErrorTypes: Array = []; if (!tryCatch.catchClause) { return thrownErrorTypes; } function visitThrowStatement(throwStmt: ts.ThrowStatement) { const thrownErrorType = checker.getTypeAtLocation(throwStmt.expression); if (!isAnyOrUnknownType(thrownErrorType)) { if (thrownErrorType.isUnion()) { for (const type of thrownErrorType.types.filter((t) => !isAnyOrUnknownType(t))) { thrownErrorTypes.push(type); } } else { thrownErrorTypes.push(thrownErrorType); } } } function visit(node: ts.Node): void { if (ts.isThrowStatement(node) && checkThrowStatement(node, sourceFile, checker)) { visitThrowStatement(node); } ts.forEachChild(node, visit); } visit(tryCatch.catchClause); return thrownErrorTypes; } function isInTryBlock(node: ts.Node, tryStatement: ts.TryStatement): boolean { let current: ts.Node | undefined = node; while (current && current !== tryStatement) { if (current === tryStatement.tryBlock) { return true; } if (current === tryStatement.catchClause || current === tryStatement.finallyBlock) { return false; } current = current.parent; } return false; } function getHandledErrorTypes( tryStatement: ts.TryStatement, checker: ts.TypeChecker, callNode: ts.Node, ): Set | 'all' { const handled = new Set(); const catchClause = tryStatement.catchClause; if (!catchClause) { return handled; } // Check if this is a catch-all (no re-throw) if (!containsThrowStatement(catchClause.block)) { return 'all'; } // If there's re-throwing, use type narrowing to find handled types if (!catchClause.variableDeclaration) { return handled; } const narrowedTypes = findNarrowedErrorTypes( catchClause.block, catchClause.variableDeclaration, checker, callNode, ); for (const type of narrowedTypes) { handled.add(checker.typeToString(type)); } return handled; } /** * Check if a block contains a throw statement (indicating re-throwing). */ function containsThrowStatement(block: ts.Block): boolean { let hasThrow = false; function visit(node: ts.Node): void { if (hasThrow) { return; } if (ts.isThrowStatement(node)) { hasThrow = true; return; } // Don't recurse into nested functions - their throws don't count if ( ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ) { return; } ts.forEachChild(node, visit); } visit(block); return hasThrow; } /** * Find error types that are narrowed in the catch block using TypeScript's type narrowing. * This supports instanceof checks, type predicates, and other type guards. */ function findNarrowedErrorTypes( block: ts.Block, errorVar: ts.VariableDeclaration, checker: ts.TypeChecker, originalCallNode: ts.Node, ): ts.Type[] { const narrowedTypes: ts.Type[] = []; const errorVarName = errorVar.name.getText(); /** * Analyze if-statements to find type narrowing branches that don't re-throw. * These represent error types that are handled. */ function visitIfStatement(ifStmt: ts.IfStatement) { // Get the type of the error variable after the type guard in the if condition // const condition = ifStmt.expression; // Check if the then-block handles the error (doesn't re-throw) const thenStatement = ifStmt.thenStatement; const thenRethrows = ts.isBlock(thenStatement) ? containsThrowStatement(thenStatement) : ts.isThrowStatement(thenStatement); if (thenRethrows) { // Get narrowed type in the then-block const narrowedType = getNarrowedTypeInBlock(thenStatement, errorVarName, checker); if (narrowedType && !isAnyOrUnknownType(narrowedType)) { // For union types, add each constituent type if (narrowedType.isUnion()) { narrowedTypes.push(...narrowedType.types.filter((t) => !isAnyOrUnknownType(t))); } else { narrowedTypes.push(narrowedType); } } } // Check else-if and else branches if (ifStmt.elseStatement) { if (ts.isIfStatement(ifStmt.elseStatement)) { visitIfStatement(ifStmt.elseStatement); } else { const elseHandles = ts.isBlock(ifStmt.elseStatement) ? !containsThrowStatement(ifStmt.elseStatement) : !ts.isThrowStatement(ifStmt.elseStatement); if (elseHandles) { const narrowedType = getNarrowedTypeInBlock(ifStmt.elseStatement, errorVarName, checker); if (narrowedType && !isAnyOrUnknownType(narrowedType)) { if (narrowedType.isUnion()) { narrowedTypes.push(...narrowedType.types.filter((t) => !isAnyOrUnknownType(t))); } else { narrowedTypes.push(narrowedType); } } } } } } function visit(node: ts.Node): void { if (ts.isIfStatement(node)) { visitIfStatement(node); } ts.forEachChild(node, visit); } visit(block); // If no narrowed types found but we found type guards with instanceof, // fall back to the old method for backwards compatibility if (narrowedTypes.length === 0) { const instanceofTypes = findInstanceofChecks(block, errorVar); for (const typeExpr of instanceofTypes) { const type = checker.getTypeAtLocation(typeExpr); if (!isAnyOrUnknownType(type)) { narrowedTypes.push(type); } } } return narrowedTypes; } /** * Get the narrowed type of a variable within a specific block by finding * the first reference to it and checking its type at that location. */ function getNarrowedTypeInBlock( block: ts.Node, varName: string, checker: ts.TypeChecker, ): ts.Type | null { let foundType: ts.Type | null = null; function visit(node: ts.Node): void { if (foundType) { return; } // Look for references to the error variable if (ts.isIdentifier(node) && node.text === varName) { // Get the type at this location (after narrowing) const type = checker.getTypeAtLocation(node); if (!isAnyOrUnknownType(type)) { foundType = type; } } // Don't recurse into nested functions if ( ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ) { return; } ts.forEachChild(node, visit); } visit(block); return foundType; } /** * Check if a type is any or unknown (which we should ignore for error handling). */ function isAnyOrUnknownType(type: ts.Type): boolean { return (type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Unknown) !== 0; } /** * Legacy function kept for backwards compatibility. * Finds instanceof checks in the catch block. */ function findInstanceofChecks(block: ts.Block, errorVar: ts.VariableDeclaration): ts.Expression[] { const checks: ts.Expression[] = []; const errorVarName = errorVar.name.getText(); function visit(node: ts.Node): void { if ( ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword ) { if (ts.isIdentifier(node.left) && node.left.text === errorVarName) { checks.push(node.right); } } ts.forEachChild(node, visit); } visit(block); return checks; } function getPropagatedErrorTypes( node: ts.Node, func: ts.FunctionLikeDeclaration, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): ts.Type[] { if (!func.type) { return []; } // If `node` is in a try/catch, then the errors propegated are the errors that the catch itself throws const tryCatch = getContainingTryCatch(node); if (tryCatch?.catchClause) { return getTryCatchThrownErrors(tryCatch, sourceFile, checker); } const returnType = checker.getTypeFromTypeNode(func.type); return extractThrowsErrorTypes(returnType, checker); } function getFunctionName(node: ts.CallExpression): string { if (ts.isIdentifier(node.expression)) { return node.expression.text; } if (ts.isPropertyAccessExpression(node.expression)) { return node.expression.name.text; } return ''; } function isPromiseRejectCall(node: ts.CallExpression): boolean { // Check for Promise.reject() if (ts.isPropertyAccessExpression(node.expression)) { const expr = node.expression; if ( ts.isIdentifier(expr.expression) && expr.expression.text === 'Promise' && ts.isIdentifier(expr.name) && expr.name.text === 'reject' ) { return true; } } return false; }