Skip to content

Declaration diagnostics allocate a diagnostic selector closure for every declaration visited #64720

Description

@gwkline

🔎 Search Terms

declaration emit performance, declaration diagnostics, allocations, setupDiagnosticContext, createGetSymbolAccessibilityDiagnosticForNode, noEmit declaration

🕗 Version & Regression Information

  • Observed on main (7.1.0-dev, aa81492, 2026-10-09).

⏯ Playground Link

Not reproducible in the playground; it needs a generated project. Generator below.

💻 Code

node gen.mjs && tsgo -p . --extendedDiagnostics

// Ordinary files with many exported declarations, checked with `declaration: true` so that declaration
// diagnostics run the declaration transformer over every declaration.
import fs from "node:fs";
const FILES = 1500, CLASSES_PER_FILE = 6;
fs.mkdirSync("src", { recursive: true });
for (let f = 0; f < FILES; f++) {
  let text = "";
  for (let c = 0; c < CLASSES_PER_FILE; c++) {
    const n = `${f}_${c}`;
    text += `export interface Options${n} { name: string; size?: number; tags: readonly string[] }
export class Service${n}<T extends object = {}> {
    private readonly items = new Map<string, T>();
    static readonly kind = "service${n}" as const;
    constructor(public readonly options: Options${n}, protected limit = 10) {}
    get count() { return this.items.size; }
    set count(value: number) { this.limit = value; }
    add(key: string, item: T, overwrite = false) { if (overwrite || !this.items.has(key)) this.items.set(key, item); return this; }
    find(predicate: (item: T, key: string) => boolean) { for (const [key, item] of this.items) if (predicate(item, key)) return { key, item }; return undefined; }
    map<U>(fn: (item: T) => U) { return [...this.items.values()].map(fn); }
}
export function create${n}(options: Options${n}) { return new Service${n}(options); }
export const defaults${n} = { name: "${n}", size: ${c}, tags: ["a", "b"] as const };
export type Item${n} = ReturnType<typeof create${n}> extends Service${n}<infer T> ? T : never;
`;
  }
  fs.writeFileSync(`src/file${f}.ts`, text);
}
fs.writeFileSync("tsconfig.json", JSON.stringify({ compilerOptions: { target: "es2022", module: "esnext", moduleResolution: "bundler", strict: true, declaration: true, noEmit: true, types: [], lib: ["es2022"] }, include: ["src"] }));

🙁 Actual behavior

14.5M allocations for a 1,557-file program with no errors.

Every declaration the declaration transformer visits installs a diagnostic context: setupDiagnosticContext calls createGetSymbolAccessibilityDiagnosticForNode, which allocates a closure that picks the message to report if a symbol used by the declaration turns out to be inaccessible. It also returns a second closure that restores the previous context. The selector is only invoked when such an error is actually reported, which almost never happens.

This runs for every emitted declaration file, and under noEmit with declaration enabled for every file's declaration diagnostics. In an 8.6k-file package of a private monorepo, the two closures were 11% of all allocations of a --noEmit check.

🙂 Expected behavior

Establishing a diagnostic context should not allocate; the selector only needs to exist once an error is reported.

Additional information about the issue

With the change in the linked PR, the program above makes 8.7M allocations (−40%) and the monorepo check makes 11% fewer. Emitted declarations and diagnostics are unchanged.

The profiling and the patch were done with Claude Code.

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