Repository navigation
Stack overflow in getEffectsSignature for long comma-separated call chains (e.g. minified bundles with allowJs) #64948
Description
🔎 Search Terms
"Maximum call stack size exceeded", getEffectsSignature, getTypeAtFlowCall, comma expression, assertion, minified bundle, allowJs
🕗 Version & Regression Information
- This is a crash
- Reproduces in 4.9.5, 5.9.3 and 6.0.3 (
RangeError: Maximum call stack size exceeded). - On
main(native compiler) the same recursion happens. It does not crash at these sizes because Go goroutine stacks grow up to 1 GB, butgetEffectsSignaturenests once per call in the chain (about 5,000 deep for the 5,000-call repro below).
⏯ Playground Link
The repro is a generated file of about 16 KB, which is too large for a playground link. The script below generates it.
💻 Code
node -e '
let s = "declare const a: { f(x: number): void };\nvar first = late;\n";
s += Array.from({ length: 2000 }, () => "a.f(0)").join(", ") + ";\nvar late = a;\n";
require("fs").writeFileSync("repro.ts", s);'
npx -p typescript@6.0.3 tsc --noEmit repro.tsrepro.ts looks like this:
declare const a: { f(x: number): void };
var first = late;
a.f(0), a.f(0), a.f(0), /* ...2000 calls in total... */ a.f(0);
var late = a;The same code with ; in place of , between the calls checks fine.
In practice this shows up when a minified bundle is included in a program with allowJs (for example, through the default include). Minifiers join statements with commas. One example is the CommonJS build of lucide-react, which has thousands of Object.defineProperty(exports, "...", { get: ... }) calls in a single comma expression.
🙁 Actual behavior
RangeError: Maximum call stack size exceeded
at checkNonNullExpression
at getEffectsSignature
at getTypeAtFlowCall
at getTypeAtFlowNode
at getFlowTypeOfReference
at checkIdentifier
at checkExpressionWorker
at checkExpression
at checkNonNullExpression
at checkPropertyAccessExpression
at checkExpressionWorker
at checkExpression
at checkNonNullExpression
at getEffectsSignature
... (repeats once per call in the chain)
🙂 Expected behavior
No crash. Only the normal TS2454: Variable 'late' is used before being assigned. error is reported, the same as for the statement form.
Additional information about the issue
The binder adds a FlowCall node for every call that is an operand of a comma expression, because such a call may be an assertion (maybeBindExpressionFlowIfCall, called for both expr.Left and expr.Right of ,):
TypeScript/tsc/internal/binder/binder.go
Lines 2165 to 2173 in 8d9b6da
| func (b *Binder) maybeBindExpressionFlowIfCall(node *ast.Node) { | |
| // A top level or comma expression call expression with a dotted function name and at least one argument | |
| // is potentially an assertion and is therefore included in the control flow. | |
| if ast.IsCallExpression(node) { | |
| if node.Expression().Kind != ast.KindSuperKeyword && ast.IsDottedName(node.Expression()) { | |
| b.currentFlow = b.createFlowCall(b.currentFlow, node) | |
| } | |
| } | |
| } |
TypeScript/tsc/internal/binder/binder.go
Lines 2262 to 2270 in 8d9b6da
| b.bind(expr.Type) | |
| if operator == ast.KindCommaToken { | |
| b.maybeBindExpressionFlowIfCall(expr.Left) | |
| } | |
| b.bind(expr.OperatorToken) | |
| b.bind(expr.Right) | |
| if operator == ast.KindCommaToken { | |
| b.maybeBindExpressionFlowIfCall(expr.Right) | |
| } |
However, getEffectsSignature uses the circularity-safe getTypeOfDottedName only when the call's parent is an ExpressionStatement. Comma operands fall through to checkNonNullExpression(node.Expression()):
TypeScript/tsc/internal/checker/flow.go
Lines 2051 to 2067 in 8d9b6da
| // A call expression parented by an expression statement is a potential assertion. Other call | |
| // expressions are potential type predicate function calls. In order to avoid triggering | |
| // circularities in control flow analysis, we use getTypeOfDottedName when resolving the call | |
| // target expression of an assertion. | |
| var funcType *Type | |
| if ast.IsBinaryExpression(node) { | |
| rightType := c.checkNonNullExpression(node.AsBinaryExpression().Right) | |
| funcType = c.getSymbolHasInstanceMethodOfObjectType(rightType) | |
| } else if ast.IsExpressionStatement(node.Parent) { | |
| funcType = c.getTypeOfDottedName(node.Expression(), nil /*diagnostic*/) | |
| } else if node.Expression().Kind != ast.KindSuperKeyword { | |
| if ast.IsOptionalChain(node) { | |
| funcType = c.checkNonNullType(c.getOptionalExpressionType(c.checkExpression(node.Expression()), node.Expression()), node.Expression()) | |
| } else { | |
| funcType = c.checkNonNullExpression(node.Expression()) | |
| } | |
| } |
Checking a.f at call n runs control flow analysis for a, which walks back to the FlowCall for call n-1. That calls getEffectsSignature for call n-1, which checks a.f there and walks back to call n-2, and so on. The recursion is one level per call. It only happens when a reference after the chain is checked before the calls themselves (here var first = late; forces late's initializer to be checked first). Otherwise links.effectsSignature fills in from left to right.
In the 6.0 JS sources, the code is the same:
https://github.com/microsoft/TypeScript/blob/050880ce59e30b356b686bd3144efe24f875ebc8/src/compiler/checker.ts#L28792-L28817
TypeScript/src/compiler/binder.ts
Lines 1779 to 1786 in 050880c
| function maybeBindExpressionFlowIfCall(node: Expression) { | |
| // A top level or comma expression call expression with a dotted function name and at least one argument | |
| // is potentially an assertion and is therefore included in the control flow. | |
| if (node.kind === SyntaxKind.CallExpression) { | |
| const call = node as CallExpression; | |
| if (call.expression.kind !== SyntaxKind.SuperKeyword && isDottedName(call.expression)) { | |
| currentFlow = createFlowCall(currentFlow, call); | |
| } |
A possible fix is to treat calls whose value is discarded the same way as expression statements: comma operands on the left, and comma operands on the right when the comma expression's own value is unused. Their targets would be resolved with getTypeOfDottedName. The TS2775 check in resolveCallExpression would apply the same rule, so an assertion through a name without an explicit type annotation in a comma expression gets the same error it already gets as a statement:
https://github.com/microsoft/TypeScript/blob/8d9b6da99ea7d4a8e7d906dd2532456dfe14e24f/tsc/internal/checker/checker.go#L8562
A call that is the value of a comma expression, as in if (log(), isString(x)), still goes through full checking. Type predicates there still narrow.
I have this change with a compiler test on a branch (https://github.com/u9g/TypeScript/tree/fix-comma-call-effects-recursion) and can open a PR if this goes to the Backlog. The investigation and patch were done with an AI coding assistant (Claude Code).
Activity
AbbasFullstack commentedon Oct 24, 2026
I would like to fix it
AbbasFullstack commentedon Oct 24, 2026
Ah, I see you already have a patch ready on your branch — I'll leave this one to you. Looking forward to the fix!
AbbasFullstack commentedon Oct 24, 2026
If you need any type help tell me I am 24/7 for help