Repository navigation
Unbounded memory in declaration emit for an inferred type built from non-exported recursive aliases (also hit by --noEmit --watch) #64708
Description
🔎 Search Terms
DeepCloneNode, conditionalTypeToTypeNode, declaration emit out of memory, recursive conditional type, non-exported type alias, TS7056, watch mode memory, noEmit watch
🕗 Version & Regression Information
- Reproduces on
7.0.2and on7.1.0-dev.20261009.1(macOS, arm64). - Not yet compared with 6.0.
⏯ Playground Link
Needs two modules (the helper aliases must live in another file and stay unexported), so the repro is below.
💻 Code
// types.ts
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : [];
type Shift<K, P extends unknown[]> = P[0] extends K ? Tail<P> : never;
type NonUndefined<T> = T extends undefined ? never : T;
type Rec<T, P extends string[]> = T extends object ? { [K in Extract<keyof T, P[0]>]: NonUndefined<Rec<T[K], Shift<K, P>>> } : T;
export type Expanded<T, P extends string> = Rec<T, [P]>;
export interface Entity { id: string }// api.ts
import type { Entity, Expanded } from "./types";
declare function get<T>(): T;
export const fetch = <P extends string>() => get<Expanded<Entity, P>>();// tsconfig.json
{ "compilerOptions": { "strict": true, "declaration": true, "emitDeclarationOnly": true, "outDir": "out", "types": [] }, "files": ["types.ts", "api.ts"] }Run tsc -p .
🙁 Actual behavior
The compiler never finishes. Memory passes 3 GB within about 5 seconds and keeps growing until the process is killed. On a real project with the same pattern in 11 modules, a full declaration emit reached 52 GB in 3.5 minutes before we stopped it.
The same thing happens in a project that never asks for declarations, through watch mode. With "noEmit": true instead of the declaration options, and a third file importing fetch from ./api:
tsc -p . --watchcompletes the initial build normally (about 90 MB,Found 0 errors).- Append a comment to
api.ts. - The watcher starts declaration emit for the changed file and grows without bound (3 GB after 5 to 7 seconds in this repro). In our project the watcher reached a 65 GB footprint.
A plain tsc -p . --noEmit is fine (about 100 MB).
Sampled stack (symbolized from the release binary, condensed):
compiler.(*Program).Emit.func2
compiler.(*emitter).emitDeclarationFile
transformers/declarations.(*DeclarationTransformer).transformVariableDeclaration
transformers/declarations.(*DeclarationTransformer).ensureType
checker.(*NodeBuilderImpl).serializeTypeForDeclaration
checker.(*NodeBuilderImpl).serializeReturnTypeForSignature
checker.(*NodeBuilderImpl).typeToTypeNode
checker.(*NodeBuilderImpl).createMappedTypeNodeFromType
checker.(*NodeBuilderImpl).conditionalTypeToTypeNode (repeats)
ast.(*NodeFactory).DeepCloneNode
ast.(*ConditionalTypeNode).VisitEachChild (very deep)
ast.(*IndexedAccessTypeNode).VisitEachChild
Most of the remaining CPU time goes to GC (scanObject, findObject) and map lookups under printer.(*EmitContext).SetOriginalEx.
🙂 Expected behavior
Emit terminates, either with a truncated type or with TS7056. The nightly already does that for close variants of this repro:
| Variant | 7.0.2 | 7.1.0-dev.20261009.1 |
|---|---|---|
| Repro as above | runaway | runaway |
Without the NonUndefined<...> wrapper |
finishes, writes a 2.5 MB api.d.ts |
TS7056, about 30 MB |
Rec<T[K], P> instead of Rec<T[K], Shift<K, P>> |
finishes, writes a 660 KB api.d.ts |
TS7056, about 30 MB |
| Helper aliases exported | about 30 MB, 220 byte api.d.ts |
about 30 MB, 220 byte api.d.ts |
Additional information about the issue
- Workaround: export the helper aliases. The printer can then reference
import("./types").Rec<...>instead of inlining the alias body into itself. - The real world shape is a common "make these dotted paths required" helper (
DeepRequiredObjwith a path to tuple split), used as the type argument of a generic API client method without an explicit return type. - Possibly related:
DeepCloneNodeallocation runaway (44 GB / 308 s) for recursive tagged-tuple type alias #63825 (closed without a repro, sameDeepCloneNoderunaway on the declaration emit path), Performance regression for declaration emit of an oversized inferred type #63966 / Avoid cloning cached declaration types after truncation #63969, Detect cycles while serializing array and tuple types #64556. The nightly includes those fixes and still reproduces this one. - The watch mode part seems worth noting on its own: a
noEmitproject can run into declaration emit problems just by saving a file, because the builder computes.d.tssignatures for changed files.
Activity