Skip to main content
Why TypeScript Typechecks Get Slow: Our Field Notes on tsgo, Project References, and isolatedDeclarations

Why TypeScript Typechecks Get Slow: Our Field Notes on tsgo, Project References, and isolatedDeclarations

September 7, 2026
Developer Tooling
10 min read

A slow tsc --noEmit is 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 --extendedDiagnostics tells you which in about ten seconds. The three changes that consistently cut typecheck time on our projects are measuring before guessing, splitting one whole-repo tsconfig.json into project references built with tsc -b, and running the Go-based native port tsgo as a second checker in CI.

Key takeaways

Running tsc --noEmit --extendedDiagnostics prints Files, Types, Instantiations, Memory used, and a time breakdown across Parse, Bind, Check, and Emit. We 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.

tsgo is the TypeScript native port: a rewrite of the compiler and language service in Go, distributed as the @typescript/native-preview npm package and intended to ship as TypeScript 7. It is the only lever on this list that speeds up checking without changing a line of application code.

Project references — composite: true in each package plus a references array in the consumer, built with tsc -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.ts file derivable from a single source file. That property is what lets declaration emit be parallelised or handed to a non-TypeScript tool.

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 the Check phase almost always dominates. The --extendedDiagnostics flag prints that split alongside the counts that explain it, which makes it the first command our team runs on any slow project.

When Check dominates, the next step is tsc --noEmit --generateTrace .trace followed by the @typescript/analyze-trace package pointed at the same directory. The trace comes back as a ranked list of the files and type instantiations that cost the most milliseconds, and the ranking is usually surprising: a single utility module exporting inferred return types is a common culprit that no code review would flag.

Two configuration flags are worth confirming before any structural work. Setting skipLibCheck to true stops the compiler from type checking the contents of .d.ts files, which on a large node_modules is often the cheapest available win; your own source is still checked against those declarations. Setting types to an empty array stops TypeScript from automatically including every package under node_modules/@types, which matters in repositories that have accumulated years of ambient globals nobody imports.

What is tsgo and should we adopt 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 team described a plan in which the native port becomes TypeScript 7 while the existing JavaScript implementation continues as TypeScript 6, so both lines exist during the transition. Their published benchmarks report order-of-magnitude improvements in check time and editor load time on the repositories they measured.

We do not treat tsgo as a replacement while it carries a preview label, and we recommend the same caution to our clients. What we do instead is cheap: add a second, non-blocking CI job that runs tsgo --noEmit next to the authoritative tsc --noEmit job, and compare the error output. When the two agree on every pull request for a month, the native binary has earned the right to become the blocking one.

The honest limits matter. The preview does not implement every compiler flag or the full public compiler API, so anything that plugs into TypeScript programmatically — custom transformers, the type-aware rules in @typescript-eslint, codemods built on ts-morph — still runs against the JavaScript compiler. A native-preview editor extension exists for VS Code, and the same caveat applies to it.

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 a ui package imports a core package and nothing else, editing ui should never re-check core.

Three details decide whether the migration is worth it. A referenced project must emit, because composite: true implies declaration: true and the consumer type checks against the emitted .d.ts rather than against source — that indirection is exactly where the caching comes from. Setting declarationMap to true keeps go-to-definition landing in real implementation files instead of generated declarations. And running tsc -b --verbose once after wiring it up 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 deserves to be stated plainly. In a monorepo where every package imports every other package, the reference graph has no layers, every change invalidates every project, and the team has 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. Enabling it is a code change, not merely a configuration change, because it forces explicit annotations on every export.

The payoff is that declaration emit no longer needs 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 declaration generation mode. The secondary benefit appears in the consuming project, where an exported function with a written return type is a type the checker reads instead of a type it re-derives.

We enable it in packages that publish declarations and in internal workspace packages consumed through project references. We leave it off in an application's leaf feature code, where the annotation burden is real and there is no declaration 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 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 cheapest correction is naming the inferred type once and annotating the export with it. Writing a User type as z.infer of the schema and then annotating the parse function's return type as User replaces a re-inference at every call site with a lookup.

Two smaller habits move the Instantiations number. Prefer an interface that extends other types over an intersection of 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 saves a lot of confused debugging: a fast command-line check and a slow editor are not a contradiction. The command line runs tsc once over the whole program, while the editor runs tsserver, which maintains an incremental program around the open files and answers completion and hover requests against it. If the CLI is quick and typing is laggy, the problem is the editor's project scope — open the TS Server log from the command palette and check which tsconfig.json it matched the file to.

Which lever should we pull first?

Setting skipLibCheck to true skips type checking inside .d.ts files and is worth enabling almost always, because it is the cheapest single flag available.

Setting incremental to true reuses the previous run's .tsbuildinfo, which helps local watch loops and helps CI only when that file is cached and restored between runs.

Adopting tsgo runs the same check on the Go compiler, which is the right first move when the goal is speed without code changes — run it beside tsc before making it authoritative.

Adopting project references with tsc -b adds per-package caching across a layered graph, and is worth it in a monorepo whose packages form a real directed acyclic graph.

Enabling isolatedDeclarations gives per-file, parallelisable declaration emit, and is worth it when a team publishes packages or when declaration emit dominates build time.

Adding explicit return types to exported functions removes repeated inference at call sites, and is the right lever when the Instantiations count is high relative to the file count.

The lesson we keep coming back to: the compiler already reports where its time goes, and teams routinely spend afternoons tuning things that were never the bottleneck because nobody asked it. Running --extendedDiagnostics costs one command, and it belongs before any migration.

FAQ

Q: Does skipLibCheck: true hide real errors in our 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 the 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 the file to, which may include far more files than expected. 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 application consumes, not the application'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 still fails CI without serialising the build behind the checker.