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

オープン
#64,708 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
12/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
go, typescript

調査の方向性

Start with the two-module repro in types.ts and api.ts, then trace the sampled stack: checker NodeBuilderImpl.typeToTypeNode, createMappedTypeNodeFromType and conditionalTypeToTypeNode, which call ast NodeFactory.DeepCloneNode on a recursive alias body. Done means the declaration emit for api.ts terminates with either a truncated type or TS7056, and the noEmit --watch case stays bounded in memory, with the exported-alias variant still producing the same output.

索引モデルが issue の本文から書いたものです。

説明

🔎 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
  • 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 (DeepRequiredObj with a path to tuple split), used as the type argument of a generic API client method without an explicit return type.
  • Possibly related: #63825 (closed without a repro, same DeepCloneNode runaway on the declaration emit path), #63966 / #63969, #64556. The nightly includes those fixes and still reproduces this one.
  • The watch mode part seems worth noting on its own: a noEmit project can run into declaration emit problems just by saving a file, because the builder computes .d.ts signatures for changed files.
主要言語
Go
スター
112k
フォーク
15.4k
平均マージ
2日 21時間
マージ済み PR(30日)
126

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

microsoft/TypeScript のほかの issue

microsoft/TypeScript の issue をすべて見る

似ている issue

Go の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。