Skip to content

False “Excessive stack depth” on circular types linked through arrays (regression from #3445) #4807

Description

@skidlucas

The text on this MR has been mostly generated by IA, but proof read and validated by me; I didn't fully understand the specifics but I let some detailed explanation on both the repo and this issue thinking it would help, sorry if it's a little bit too verbose.

Steps to reproduce

Repro repository (single 322-line file, no dependencies): https://github.com/skidlucas/tsgo-circular-array-depth

git clone https://github.com/skidlucas/tsgo-circular-array-depth && cd tsgo-circular-array-depth
npx -p typescript@6     tsc -p tsconfig.json
npx -p typescript@7.0.2 tsc -p tsconfig.json --singleThreaded
npx -p typescript@7.0.2 tsc -p tsconfig.json

src/ring.ts is two parallel rings of 40 interfaces, each linked to the next by an
optional array property, plus one cross-ring assignment (A and B are structurally
identical, so the assignment is valid):

interface A0 {                 // ... A1, A2, ... A39, whose `next` closes the
  name: string;                //     ring back onto A0. B0..B39 are identical.
  next?: A1[];
}
declare const a: A0;
const assigned: B0 = a;

Behavior with typescript@6.0

0 errors.

Behavior with tsgo

src/ring.ts(322,7): error TS2321: Excessive stack depth comparing types 'A0' and 'B0'.

In every mode, including --singleThreaded, this is not a concurrency effect.

  • Threshold: ring size 33 passes, 34 fails (node gen.mjs <M> regenerates src/).
  • The optional array step is required: with next?: A_k (no array) or
    next: A_k[] (not optional), tsgo agrees with tsc at every size.
  • Real-world impact: on a NestJS + TypeORM codebase (~12,000 files; entity classes and
    interfaces cross-linked through array/optional relation properties), tsc 6.0.3
    reports 0 errors, tsgo 7.0.2 reports 30 with --singleThreaded and 680 in default
    mode.

The ring is a distilled version of our real-world shape: a shared package
declares plain interfaces (ICompany, ISupplierInvoice, …) and the server package
declares TypeORM entity classes implementing them
(class SupplierInvoice implements ISupplierInvoice). ORM relations make both graphs
cyclic and array-linked (invoices?: SupplierInvoice[], company: Company).

Root cause

Each ring hop pushes 3 entries onto the relation stacks (interface pair,
A_k[] | undefined union pair, A_k[] array pair), so the walk hits the depth limit
of 100 at hop ~34 - during its first lap, before the maybe-keys cycle detection
(which fires on pair revisit) gets a chance. In tsc, isDeeplyNestedType cuts the
walk long before that: all A_k[] share the Array symbol as recursion identity, so
the third array instantiation on the stack triggers the deeply-nested heuristic and
returns Ternary.Maybe.

In tsgo, the ObjectFlagsFromTypeNode guard added to getRecursionIdentity by #3445
excludes type-node-originated references from symbol identity, so each A_k[] gets a
unique identity and the heuristic never fires. An instrumented build dumping the
source stack at the overflow point:

OVERFLOW comparing A34[] | undefined vs B34[] | undefined (sourceStack=100 targetStack=100)
  src[0] A0                 idKind=symbol:A0
  src[1] A1[] | undefined   idKind=objectIdentity
  src[2] A1[]               idKind=objectIdentity(FromTypeNode-blocked-symbol:Array)
  src[3] A1                 idKind=symbol:A1
  ...

I understand #3445 is intentional (it fixed #3426, where the shared-Array identity
cut walks through deeply nested acyclic arrays and missed real errors - I verified
tsc 6.0.3 indeed misses the error in that example while tsgo 7.0.2 catches it). But
before #3445, that cut was incidentally what kept finite-but-long cyclic walks under
the 100-frame budget; with it gone, valid array-linked cyclic graphs - the shape of
every ORM entity graph - now overflow.

A tested fix candidate

Use the pre-#3445 identities as a fallback at the depth limit only: when the limit
is hit and ≥3 stack entries share a legacy recursion identity (on both stacks), return
Ternary.Maybe - the verdict the old heuristic would have produced - instead of
reporting TS2321. Walks that never repeat a legacy identity (like #3426's example) are
unaffected and keep the full benefit of #3445's stricter identities.

Prototyped on a local build (~40 lines in relater.go; proof-of-concept patch:
https://github.com/skidlucas/tsgo-circular-array-depth/blob/main/fix-legacy-identity-fallback.patch):

Test tsgo 7.0.2 with fallback tsc 6.0.3
The ring above (valid) 1 false error 0 0
#3445's example (real error) caught still caught missed
Recursive mapped type overflow (generative) error still error error
Cold 150-deep acyclic chain error still error error
Real-world codebase (mono / multi) 30 / 680 3 / 3 0

(The remaining 3 real-world errors are an unrelated @sendgrid/mail d.ts issue. A
proper implementation would reuse isDeeplyNestedType parameterized by identity
function rather than a flat count, and I have not run the tsgo baseline suite.)

Environment

  • typescript 7.0.2 (tsgo) / typescript 6.0.3 baseline
  • Node v24.11.1, macOS 15 (Darwin 25.5.0), Apple M4 Pro
  • strict: true, skipLibCheck: true

Metadata

Metadata

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions