Skip to content

Stack overflow in getEffectsSignature for long comma-separated call chains (e.g. minified bundles with allowJs) #64948

Open

Description

@u9g

🔎 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, but getEffectsSignature nests 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.ts

repro.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 ,):

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)
}
}
}

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()):

// 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

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

  1. AbbasFullstack commented on Oct 24, 2026

    @AbbasFullstack

    I would like to fix it

  2. AbbasFullstack commented on Oct 24, 2026

    @AbbasFullstack

    Ah, I see you already have a patch ready on your branch — I'll leave this one to you. Looking forward to the fix!

  3. AbbasFullstack commented on Oct 24, 2026

    @AbbasFullstack

    If you need any type help tell me I am 24/7 for help

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions