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
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
src/ring.tsis two parallel rings of 40 interfaces, each linked to the next by anoptional array property, plus one cross-ring assignment (A and B are structurally
identical, so the assignment is valid):
Behavior with
typescript@6.00 errors.
Behavior with
tsgoIn every mode, including
--singleThreaded, this is not a concurrency effect.node gen.mjs <M>regeneratessrc/).next?: A_k(no array) ornext: A_k[](not optional), tsgo agrees with tsc at every size.interfaces cross-linked through array/optional relation properties), tsc 6.0.3
reports 0 errors, tsgo 7.0.2 reports 30 with
--singleThreadedand 680 in defaultmode.
The ring is a distilled version of our real-world shape: a shared package
declares plain interfaces (
ICompany,ISupplierInvoice, …) and the server packagedeclares TypeORM entity classes implementing them
(
class SupplierInvoice implements ISupplierInvoice). ORM relations make both graphscyclic and array-linked (
invoices?: SupplierInvoice[],company: Company).Root cause
Each ring hop pushes 3 entries onto the relation stacks (interface pair,
A_k[] | undefinedunion pair,A_k[]array pair), so the walk hits the depth limitof 100 at hop ~34 - during its first lap, before the maybe-keys cycle detection
(which fires on pair revisit) gets a chance. In tsc,
isDeeplyNestedTypecuts thewalk long before that: all
A_k[]share theArraysymbol as recursion identity, sothe third array instantiation on the stack triggers the deeply-nested heuristic and
returns
Ternary.Maybe.In tsgo, the
ObjectFlagsFromTypeNodeguard added togetRecursionIdentityby #3445excludes type-node-originated references from symbol identity, so each
A_k[]gets aunique identity and the heuristic never fires. An instrumented build dumping the
source stack at the overflow point:
I understand #3445 is intentional (it fixed #3426, where the shared-
Arrayidentitycut 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 ofreporting 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):
(The remaining 3 real-world errors are an unrelated
@sendgrid/maild.ts issue. Aproper implementation would reuse
isDeeplyNestedTypeparameterized by identityfunction rather than a flat count, and I have not run the tsgo baseline suite.)
Environment
strict: true,skipLibCheck: true