Keep Build Times Sane: incremental and What Actually Slows tsc
Measure a build with extendedDiagnostics, then try skipLibCheck, a type that costs too much and incremental, with the numbers from a real run. After this, you change a build setting because you measured, not because you guessed.
Course content
Sign in to save your progress.
-
Types That Compute
-
Conditional Types and infer Without Nested Nightmares
-
Template Literal Types for Routes and Event Names
-
-
-
Brands and Boundaries
-
Branded Types So IDs Don't Mix
-
satisfies and const Assertions When Inference Isn't Enough
-
-
-
Declare What JavaScript Didn't
-
Writing .d.ts for a Plain JS Module
-
Ambient Modules and Global Augmentation Without Breaking Everything
-
-
-
Projects That Scale
-
Project References and Solution-Style tsconfig
-
typescript-eslint Rules That Catch Real Mistakes
-
-
-
Migrate and Keep Moving
-
Move a JavaScript Folder to TypeScript Without a Big Bang
-
allowJs, checkJs and Leaving JSDoc Behind
-
Keep Build Times Sane: incremental and What Actually Slows tsc
-
-
- Final Check: TypeScript Advanced
Skip ahead: If you already read --extendedDiagnostics output and only want what to change, skip to "Fix 1: skipLibCheck", then continue.
TypeScript 7 is much faster than the versions before it. The announcement quotes full builds that are 8 to 12 times quicker, and a small project now checks in a fraction of a second. That makes it tempting to stop caring about build time. I would not, for one reason: the speedup is a multiplier, and a project that was slow for a bad reason is still slow for the same reason, just 10 times less so. This lesson is about finding the reason, with numbers, before you change any setting.
The numbers in this lesson come from one run on the machine that built the course. Counts such as files and types are the same on any machine. Times are not, so read them as a comparison between two runs.
The test project
It has one small file that uses Express and Node types. A second file, with a large type, comes later.
{
"name": "builddemo",
"private": true,
"type": "module",
"devDependencies": {
"typescript": "7.0.2",
"@types/node": "^26.6.5",
"@types/express": "^5.0.6"
}
}
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}
import express from "express";
import { readFileSync } from "node:fs";
export const app = express();
export const read = readFileSync;
npm install --no-audit --no-fund --loglevel=error
Measure first
--extendedDiagnostics prints what the compiler did. Four lines matter.
npx tsc --extendedDiagnostics | grep -E "^(Files|Types|Instantiations):"
Files: 202
Types: 79306
Instantiations: 101294
Files is how many files are in the program, including the lib.*.d.ts files that come with the compiler. Types is how many distinct types the checker created, and it is the best single measure of how hard it worked. Instantiations counts how often a generic type was filled in. Time is the line everyone looks at, but it moves with the machine. These three do not.
npx tsc --extendedDiagnostics | grep -E "^(Check time|Total time):"
Check time: 0.329s
Total time: 0.388s
Fix 1: skipLibCheck
Where do 200 files come from, when the project has one source file? Ask the compiler to list them and count by folder: the compiler's own lib files, @types, undici-types (which @types/node pulls in) and your src.
npx tsc --listFiles | grep -c "/lib/lib\."
npx tsc --listFiles | grep -c "/node_modules/@types/"
npx tsc --listFiles | grep -c "/node_modules/undici-types/"
npx tsc --listFiles | grep -c "/src/"
65
92
44
1
Almost everything is declaration files that you did not write. By default, the compiler checks them like your own code, on every run, even though you cannot change them. skipLibCheck turns that off. It stops the compiler from checking .d.ts files. Your own code, and how it uses the libraries, are still checked.
npx tsc --extendedDiagnostics --skipLibCheck | grep -E "^(Files|Types|Instantiations):"
Files: 202
Types: 348
Instantiations: 0
The files are the same. The types fall from tens of thousands to a few hundred, because the compiler no longer checks Express's and Node's own declarations. For a small project that saves a fraction of a second. For a large one it is often the single biggest saving, and it is the one setting I would turn on first.
⚠ Warning: skipLibCheck also hides a real mistake: two libraries whose declarations disagree. If a type from a package looks wrong, turn it off for one run and read what it reports.
Fix 2: find the type that costs the most
The other cause of a slow check is a type you wrote. Add src/codes.ts. It builds a four-digit code from ten digits, which is 10,000 members, and then makes a mapped type with 10,000 properties.
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
export type Code = `${Digit}${Digit}${Digit}${Digit}`;
export type Table = { [K in Code]: { code: K; label: `code-${K}` } };
export const label: Table["1234"]["label"] = "code-1234";
npx tsc --extendedDiagnostics --skipLibCheck | grep -E "^(Types|Instantiations):"
Types: 11491
Instantiations: 3
Compare that with the previous count: 348 types became 11,491. A file of four short lines added about 11,000 types, which is more than the compiler had created for everything else once the libraries were skipped. Nothing looked wrong in the editor, because each line is small. When the numbers jump, --generateTrace shows where.
npx tsc --generateTrace trace --skipLibCheck
ls trace
legend.json
trace.json
types_0.json
types_1.json
types_2.json
types_3.json
trace.json opens in chrome://tracing or in Perfetto and shows how long each file took to check. types_0.json and its siblings list the types, so you can search for the one that has thousands of members. Then replace it with something coarser. A code like 1234 rarely needs ten thousand known members, and the number template type in the next file says the useful part in one line.
export type Code = `${number}`;
export type Table = Record<Code, { code: Code; label: string }>;
export const label: Table["1234"]["label"] = "code-1234";
npx tsc --extendedDiagnostics --skipLibCheck | grep -E "^(Types|Instantiations):"
Types: 361
Instantiations: 4
💡 Tip: Keep a record of the Types number for your repository, as you would for bundle size. A pull request that doubles it deserves a question, whatever the build time says.
Fix 3: incremental
incremental saves what the compiler learned into a .tsbuildinfo file. Next time it checks only the files that changed and the files that depend on them. It is on automatically with --build and composite. For a plain tsc run you turn it on.
rm -f tsconfig.tsbuildinfo
npx tsc --incremental --extendedDiagnostics | grep -E "^(Types|Total time):"
ls *.tsbuildinfo
Types: 79315
Total time: 0.445s
tsconfig.tsbuildinfo
That first run did the full check, libraries included, and wrote tsconfig.tsbuildinfo. Run it again, then change one file and run it a third time.
npx tsc --incremental --extendedDiagnostics | grep -E "^(Types|Total time):"
echo "export const touched = 1;" >> src/app.ts
npx tsc --incremental --extendedDiagnostics | grep -E "^(Types|Total time):"
Types: 340
Total time: 0.076s
Types: 350
Total time: 0.086s
The second run found nothing changed and skipped almost everything. The third rechecked the one file I touched and what depends on it. Both are about a fifth of the first run, and the Types line shows why: the saved state already knows the library declarations are fine. Keep the .tsbuildinfo file out of version control, and in CI cache it between runs or the benefit is gone. On a small project like this the saving is a few tenths of a second. In --watch mode and in large repositories, where the first full check is the part that hurts, it is the difference between waiting and not waiting.
The other levers
- Narrow the include list. A pattern that matches every file under the project reaches a
scriptsorfixturesfolder checks files that never ship. Compare--listFileswith what you expected. - Project references. Splitting a repository into projects, as in the earlier lesson, lets
tsc -bskip projects that did not change. - The checkers flags. TypeScript 7 checks with several workers. The default is four. More can be faster on a big machine and costs memory; fewer helps on a small CI runner. The
--singleThreadedflag turns the parallelism off, which is useful for debugging and for comparing against TypeScript 6. Change these only after you have measured.
Common mistakes
- Turning on
incrementaland committing the.tsbuildinfofile. - Setting
skipLibCheckand forgetting why. Write the reason next to it. - Blaming the compiler for a slow editor. The editor and
tscrun the same checker, so a type that costs a second in one will cost it in the other.
If you skip this, you will fix a slow build by buying a bigger runner, and the file that caused it will still be there. What is the Types number for your repository today, and who would notice if it doubled?
About
Measure a build with extendedDiagnostics, then try skipLibCheck, a type that costs too much and incremental, with the numbers from a real run. After this, you change a build setting because you measured, not because you guessed.
- Duration
- 10 min
- Last update
- 10/12/2026
Comments (0)
Sign in to commentNo comments yet.