Skip to content

Unbounded memory in declaration emit for an inferred type built from non-exported recursive aliases (also hit by --noEmit --watch) #64708

Open

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.2 and on 7.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:

  1. tsc -p . --watch completes the initial build normally (about 90 MB, Found 0 errors).
  2. Append a comment to api.ts.
  3. 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

Activity

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