Headline: A slow
tsc --noEmitis almost never caused by the number of files in a repository; it is caused by how much type instantiation the checker has to do, and--extendedDiagnosticstells you which in about ten seconds. The three changes that cut my typecheck time were measuring before guessing, splitting one whole-repotsconfig.jsoninto project references built withtsc -b, and running the Go-based native porttsgoas a second checker in CI.
Key takeaways
-
tsc --noEmit --extendedDiagnosticsprints Files, Types, Instantiations, Memory used, and a time breakdown across Parse, Bind, Check, and Emit. Read that output before changing any configuration, because the fix for a slow Parse phase and the fix for a slow Check phase have nothing in common. - Instantiations — the count of times the checker materialises a generic type with concrete arguments — predicts checker cost far better than file count. A project with 2,000 files and millions of instantiations checks slower than one with 8,000 files and few.
-
tsgois the TypeScript native port: a rewrite of the compiler and language service in Go, distributed as the@typescript/native-previewnpm package and intended to ship as TypeScript 7. It is the only item on this list that speeds up checking without changing a line of your code. - Project references —
composite: truein each package plus areferencesarray in the consumer, built withtsc -b— turn one whole-repo check into a graph of independently cacheable checks. They pay off only when the dependency graph is genuinely layered. -
isolatedDeclarations, added in TypeScript 5.5, requires explicit type annotations on exported values, and in exchange makes a.d.tsfile derivable from a single source file. That is the property that lets declaration emit be parallelised or handed to a non-TypeScript tool.
The first symptom was not CI. It was the moment I started alt-tabbing away during npm run typecheck, which is how a build step quietly stops being part of the inner loop. My assumption was the obvious one — the repository had grown, so of course the compiler was slower. That assumption was wrong, and I only found out because I ran the diagnostics flag before touching the configuration.
Where does tsc actually spend its time?
tsc splits its work into four measurable phases — Parse, Bind, Check, and Emit — and in a large application Check almost always dominates. --extendedDiagnostics prints the split alongside the counts that explain it, so it is the first command to run and the only honest starting point.
npx tsc --noEmit --extendedDiagnostics
# if Check dominates, find the specific hot spots
npx tsc --noEmit --generateTrace .trace
npx @typescript/analyze-trace .trace
--generateTrace writes a Chrome-tracing-format file, and the @typescript/analyze-trace package reads it back as a ranked list of the files and type instantiations that cost the most milliseconds. That ranking is usually surprising: in my case a single utility module exporting inferred return types was responsible for a disproportionate share of the check, and nobody would have guessed it from reading the code.
Two configuration flags are worth confirming before any structural work. skipLibCheck: true stops the compiler from type checking the contents of .d.ts files, which on a large node_modules is often the single cheapest win available; your own source is still checked against those declarations. And types: [] in compilerOptions stops TypeScript from automatically including every package under node_modules/@types, which matters once a repository has accumulated a decade of ambient globals nobody imports.
What is tsgo and should I use it now?
tsgo is a port of the TypeScript compiler and language service from TypeScript to Go, announced by the TypeScript team in March 2025 and published as a preview under the @typescript/native-preview package. The plan the team described is that the native port becomes TypeScript 7 while the existing JavaScript implementation continues as TypeScript 6, so both lines exist during the transition. The team's published benchmarks report order-of-magnitude improvements in check time and editor load time on the repositories they measured.
npm i -D @typescript/native-preview
npx tsgo --noEmit # same flags you already pass to tsc
npx tsgo --version
I do not treat it as a replacement yet, and neither should you while it carries a preview label. What I do instead is cheap and has caught nothing but time savings so far: add a second, non-blocking CI job that runs tsgo --noEmit next to the real tsc --noEmit job, and compare the error output. When the two agree on every pull request for a month, the preview binary has earned the right to become the blocking one.
The honest limits: the preview does not implement every compiler flag or the full public compiler API, so anything that plugs into TypeScript programmatically — custom transformers, ESLint's type-aware rules through @typescript-eslint, codemod tooling built on ts-morph — still runs against the JavaScript compiler. A native-preview VS Code extension exists for the editor side, and the same caveat applies there.
When do project references actually help?
Project references help when the repository is a layered dependency graph, and do nothing when it is a ball of mud. A composite project emits .d.ts files and a .tsbuildinfo fingerprint, and tsc -b walks the reference graph in dependency order and skips any project whose inputs have not changed. If packages/ui imports packages/core and nothing else, editing packages/ui should never re-check packages/core.
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
}
}
// apps/web/tsconfig.json
{
"compilerOptions": { "noEmit": true },
"references": [{ "path": "../../packages/core" }]
}
Three details decide whether this is worth the migration. A referenced project must emit — composite: true implies declaration: true — because the consumer type checks against the emitted .d.ts rather than against source, which is precisely where the caching comes from. Set declarationMap: true or every go-to-definition in your editor lands in a generated declaration file instead of the real implementation. And run tsc -b --verbose once after wiring it up: it names each project it skips as up to date, which is the only direct evidence that the cache is working rather than silently rebuilding everything.
The failure case is worth stating plainly. In a monorepo where every package imports every other package, the reference graph has no layers, every change invalidates every project, and you have added configuration complexity for no cache hits. Fix the import graph first, or skip references entirely.
What does isolatedDeclarations buy?
isolatedDeclarations is a TypeScript 5.5 compiler flag that reports an error whenever a declaration file cannot be produced from one source file in isolation — in practice, whenever an exported value's type is inferred from something in another module. Turning it on is a code change, not just a config change, because it forces explicit annotations on your exports.
// tsconfig.json → { "compilerOptions": { "isolatedDeclarations": true, "declaration": true } }
// error TS9007: Declaration emit for this file requires an explicit type annotation.
export function makeClient(url: string) {
return { url, get: (p: string) => fetch(url + p) };
}
// accepted: the exported shape is written down, not inferred
export interface Client {
url: string;
get: (p: string) => Promise<Response>;
}
export function makeClient(url: string): Client {
return { url, get: (p) => fetch(url + p) };
}
The payoff is that declaration emit stops needing a whole-program type check, so it can be parallelised per file or delegated to a faster non-TypeScript emitter — the Rust-based oxc toolchain implements exactly this, and bundlers built on it expose it as a .d.ts generation mode. The secondary benefit shows up in the consuming project: an exported function with a written return type is a type the checker reads instead of a type it re-derives.
Where I would enable it: packages that publish declarations, and internal workspace packages consumed via project references. Where I would not: an application's leaf feature code, where the annotation burden is real and there is no .d.ts emit to accelerate.
Which type patterns are expensive?
The expensive patterns are the ones that make the checker do work per call site rather than once. Deeply nested conditional types, recursive template literal types, large unions that get distributed across a mapped type, and long inference chains through schema libraries all share that shape: the cost is paid again everywhere the type is used.
// the return type is re-inferred through the schema at every call site
export const parseUser = (raw: unknown) => UserSchema.parse(raw);
// named once, then read rather than re-derived
export type User = z.infer<typeof UserSchema>;
export const parseUser = (raw: unknown): User => UserSchema.parse(raw);
Two smaller habits move the Instantiations number. Prefer interface X extends A, B over type X = A & B for large object types, because interfaces are resolved lazily and cached by name while intersections are recomputed structurally. And annotate the return type of every exported function, which is the same discipline isolatedDeclarations enforces mechanically.
One clarification that saves a lot of confused debugging: a fast CLI check and a slow editor are not a contradiction. The command line runs tsc once over the whole program, while your editor runs tsserver, which maintains an incremental program around the files you have open and answers completion and hover requests against it. If the CLI is quick and typing is laggy, the problem is in the editor's project scope — open the TS Server log from the command palette and check which tsconfig.json it decided your file belongs to.
Which lever should I pull first?
| Change | What it does | Reach for it when |
|---|---|---|
skipLibCheck: true |
Skips type checking inside .d.ts files |
Almost always; it is the cheapest single flag |
incremental: true |
Reuses the previous run's .tsbuildinfo
|
Local watch loops, and CI only if you cache the file |
tsgo (native port) |
Runs the same check on the Go compiler | You want speed without code changes; run beside tsc first |
Project references + tsc -b
|
Per-package caching across a layered graph | A monorepo whose packages form a real DAG |
isolatedDeclarations |
Per-file, parallelisable .d.ts emit |
You publish packages or declaration emit dominates |
| Explicit return types on exports | Removes repeated inference at call sites | Instantiations is high relative to file count |
The lesson I would keep if I lost the rest: the compiler already reports where its time goes, and I spent two afternoons tuning things that were never the bottleneck because I did not ask it. --extendedDiagnostics costs one command. Run it before you migrate anything.
FAQ
Q: Does skipLibCheck: true hide real errors in my own code?
A: No. It only skips type checking inside .d.ts files; your source is still checked against those declarations. The trade-off is that genuinely conflicting types between two libraries will not be reported.
Q: Is tsgo safe to use as the only typecheck in CI today?
A: Not while it ships as @typescript/native-preview. Run it as a second, non-blocking job alongside tsc --noEmit and compare error output over real pull requests before making it authoritative.
Q: Why is my editor slow when the command-line typecheck is fast?
A: The editor runs tsserver with its own incremental program scoped to the tsconfig.json it matched your file to, which may include far more files than you expect. Open the TS Server log from the command palette to see the resolved project and its file count.
Q: Does incremental: true help in CI?
A: Only if the .tsbuildinfo file is cached and restored between runs. On a clean checkout with no restored cache, the flag costs a small amount of write time and saves nothing.
Q: Do project references speed up next build?
A: They speed up the workspace packages the app consumes, not the app's own check. The common arrangement is to set typescript.ignoreBuildErrors in next.config.ts and run a dedicated typecheck job in parallel, so a type error fails CI without serialising the build behind the checker.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)