skip to content

A TypeScript project's `tsc --noEmit` has crept from twenty seconds to four minutes with no new dependencies. How do you find which types are responsible?

level: seniorimportance: should knowfreq 38%

answer

  1. measure before rewriting
  2. per-phase timings and instantiation counts
  3. trace the compiler, then analyse the trace
  4. eliminate lib checking and file inclusion
  5. bisect to confirm, then re-measure

basics

~20 s

Measure before rewriting anything. Run tsc with --extendedDiagnostics to see whether check time and instantiation counts dominate, then --generateTrace into a directory and analyse the trace with @typescript/analyze-trace to get the specific files and positions that are slow.

solid answer

~50 s

Start with `tsc --noEmit --extendedDiagnostics`. It prints per-phase timings — parse, bind, check, emit — plus counts of types and instantiations. If check time and a huge instantiation count dominate while file count is flat, the cost is type-level work rather than sheer project size. Then run `tsc --noEmit --generateTrace traceDir` and feed the output to `@typescript/analyze-trace`, which reports the hot spots by file and position so you stop guessing which helper is expensive. Along the way, rule out the easy causes: `skipLibCheck` tells you how much of the time was dependency `.d.ts` checking, and `--incremental` or project references change the baseline you are measuring against. Once you have a culprit, confirm by bisecting — comment the helper out, or replace it with a plain explicit type, and re-measure. The fixes are usually annotating exported return types, splitting or constraining the helper, or dropping the type-level version entirely.

code

bash · 9 lines
bash
# 1. Triage: per-phase timings plus type and instantiation counts
npx tsc --noEmit --extendedDiagnostics

# 2. Localise: emit a compiler trace, then find the hot spots
npx tsc --noEmit --generateTrace traceDir
npx @typescript/analyze-trace traceDir

# 3. Isolate dependency .d.ts cost
npx tsc --noEmit --skipLibCheck --extendedDiagnostics

go deeper

for a junior

Know that the TypeScript compiler can report its own timings, and that a slow type-check is investigated with measurements rather than by guessing which type looks complicated.

for a middle

Be able to run tsc --noEmit --extendedDiagnostics and read it: which phase dominates, how big the instantiation count is, and whether the file count grew.

for a senior

Show a full method — measure, trace with --generateTrace and analyse it, eliminate dependency and inclusion causes, bisect to confirm causation, fix, re-measure — and check the editor, not just CI.

for a principal

Own the prevention: a type-check budget tracked in CI, a policy for when a costly helper is worth its build time, and clarity that developer editor latency is the metric the team actually feels.

## Measure first — the culprit is rarely where you think The failure mode in this situation is a senior engineer rewriting the type helper they personally dislike, shipping it, and finding the build is still four minutes. Type-check cost is unintuitive because it is driven by instantiation counts and comparison work, which do not correlate with how clever a type *looks*. So the workflow is measure, localise, fix, re-measure. ## Step 1: `--extendedDiagnostics` ```bash npx tsc --noEmit --extendedDiagnostics ``` This prints a summary that includes file, line and node counts, the number of **Types** and **Instantiations** the checker created, memory used, and a breakdown of time by phase — I/O read, parse, bind, check, emit, total. Read it as a triage table: - **Check time dominates, instantiations enormous, file count flat** — this is type-level work. Continue to tracing. - **Parse and I/O time dominate, file count large** — this is project size and file inclusion, not clever types; look at what is being pulled into the program. - **Memory is very high** — often big unions or deep recursive instantiation. Run it before and after a change so you have a number to compare, and run it a couple of times: a cold first run on a large repo is not comparable with a warm one. ## Step 2: `--generateTrace` plus the analyzer ```bash npx tsc --noEmit --generateTrace traceDir npx @typescript/analyze-trace traceDir ``` `--generateTrace` writes a trace of the compiler's work into the given directory. The `@typescript/analyze-trace` package — published by the TypeScript team for exactly this job — reads that output and prints the hottest spots attributed to files and source positions, so the answer is "this helper at this line took 40 seconds" rather than a hunch. The raw trace is also a standard trace format that a trace viewer can open, but the analyzer is the fast path. ## Step 3: rule out the boring explanations Before blaming your own types, eliminate the causes that have nothing to do with them: - **Dependency declaration files.** `skipLibCheck` skips type checking of `.d.ts` files. Toggling it tells you how much of the time was spent checking dependencies rather than your source. (Whether to leave it on is a separate policy question.) - **What is in the program at all.** If far more files are being included than you expected, the cost is inclusion, not cleverness. - **Incremental state.** With `--incremental`, or with project references, a warm build and a cold build are wildly different numbers; make sure you are comparing like with like. - **The machine.** A CI runner change or a memory-constrained container can produce the same symptom with no code change at all. ## Step 4: confirm by bisecting Tracing points at a location; it does not prove causation. Confirm the way you would confirm any performance hypothesis: remove or neuter the suspect — replace the helper's body with a plain explicit type, or `any` it temporarily — and re-run `--extendedDiagnostics`. If the number does not move, you had the wrong suspect. Git history is the other bisection axis: the build crept up over a quarter, so `git bisect` against a timed type-check will find the commit even when the trace is ambiguous. ## Step 5: fix, in rough order of leverage 1. **Annotate return types on exported functions.** Cheapest and usually the biggest single win, because it stops the checker inferring and re-expanding a large anonymous type at every call site. 2. **Split the helper into named intermediate aliases**, which improves reuse of cached relation results and, as a bonus, makes the error messages legible. 3. **Constrain type parameters** so the checker explores less. 4. **Reduce union size** — a cross-product that produced tens of thousands of members is a common single cause. 5. **Delete the cleverness.** If the helper's guarantee could be written as a plain interface, that is very often the correct fix, not a defeat. If the guarantee is really about external data, it belongs in a runtime validator anyway. ## Don't forget the editor The number your teammates feel is not `tsc`; it is the responsiveness of the TypeScript server behind their editor, which re-answers completion and hover queries as they type. A change that halves CI time but leaves one file's completions at two seconds has not solved the complaint people actually have. Verify the fix by opening the worst file and typing in it, and if the editor is the main symptom, capture the TypeScript server's own log from your editor to see which requests are slow. ## What the interviewer is listening for A process, in order, with real tool names: measure with `--extendedDiagnostics`, localise with `--generateTrace` and the analyzer, eliminate the boring causes, bisect to confirm, then fix and re-measure. Candidates who jump straight to "I'd rewrite the conditional types" are describing a guess; candidates who mention only "turn on `skipLibCheck`" have one trick rather than a method.

  • What in the `--extendedDiagnostics` output points specifically at type-level work rather than project size?
    A check time that dwarfs parse and bind, combined with a very large Instantiations count while the file and line counts are unchanged from before the regression. Growth in parse and I/O time with a larger file count means the program is simply including more code, which is a different problem with different fixes.
  • The trace points at a helper, but removing it barely changes the total. What now?
    Treat it as a failed hypothesis and keep bisecting. Trace attribution can land on the place where an expensive instantiation is realised rather than where it originates, so check the callers that supply the type arguments, and use `git bisect` against a timed type-check to find the commit that introduced the regression.
  • How would you stop this regression happening again?
    Put a number in CI: record type-check duration, or the instantiation count from `--extendedDiagnostics`, and fail or warn when it crosses a threshold. Then reviewers have an objective signal instead of arguing about taste, and a helper that costs a minute has to justify itself when it lands rather than a quarter later.
  • Does the same investigation apply when the complaint is a slow editor rather than a slow build?
    Mostly, since it is the same checker doing the same work. Add the editor-side evidence though: capture the TypeScript server's log from your editor to see which requests are slow and in which file, because editor cost concentrates in the files people actually have open rather than spreading across the whole program.

saying these in an interview costs you the question

  • Rewrites the suspect helper without measuring anything first
  • Believes skipLibCheck fixes slowness caused by your own types
  • Treats a trace hotspot as proof without bisecting
  • Compares a cold build against a warm incremental one
  • Ignores editor responsiveness once CI time looks acceptable

context