Skip to main content
TypeScript · lesson 20 of 21
TypeScript advanced Lesson 20 of 21

TypeScript Performance

Speed up tsc with TypeScript 7, incremental builds, Project References and skipLibCheck, and profile slow type checks, all measured on real runs.

By SkillByExample · Updated

Why TypeScript Performance Matters

In a large codebase, type checking becomes the slow part of both the editor and CI. The checker infers types, instantiates generics, evaluates conditional types and compares every assignment, and that work grows with the size of your types, not only the number of lines. Most fixes are configuration changes, but you should only make them after measuring. Every example below was run with TypeScript 7.0.2, the native compiler, on Node v24.18.1, on a 12-core Windows machine. The timings are real, but they depend on your machine and vary from run to run, sometimes by 50% or more on a busy laptop. Compare runs on the same machine, repeat any number you plan to act on, and expect your own numbers to differ from these.

Measure First: --extendedDiagnostics

To get something worth measuring, gen.mjs writes 400 modules that each import the one before, plus an index.ts that imports the last.

import { mkdirSync, writeFileSync } from "node:fs";

const N = Number(process.argv[2] ?? 400);
mkdirSync("src", { recursive: true });
for (let i = 0; i < N; i++) {
  const prev = i - 1;
  const imp = i > 0 ? `import { make${prev} } from "./mod${prev}";\n` : "";
  const base = i > 0 ? `make${prev}(n)` : `{}`;
  writeFileSync(`src/mod${i}.ts`, `${imp}
export interface Shape${i} { id: number; tags: string[] }

export function make${i}(n: number) {
  const s: Shape${i} = { id: n, tags: [String(n)] };
  return { ...${base}, ...s, k${i}: n };
}
`);
}
writeFileSync("src/index.ts",
  `import { make${N - 1} } from "./mod${N - 1}";\n` +
  `console.log(Object.keys(make${N - 1}(1)).length);\n`);
console.log(`wrote ${N + 1} files to src/`);
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
$ node gen.mjs
wrote 401 files to src/
$ npx tsc --extendedDiagnostics
Files:              464
Lines:            59746
Identifiers:      55294
Symbols:          63478
Types:            45047
Instantiations:   28115
Memory used:    106153K
Memory allocs:   655470
Config time:     0.001s
Parse time:      0.070s
Bind time:       0.014s
Check time:      0.422s
Emit time:       0.107s
Total time:      0.616s
$ node dist/index.js
402

Files is 464, not 401, because the 63 other files are the default lib.*.d.ts files, and Lines counts those too. Check time is the type checker and Emit time is writing JavaScript. Types and Instantiations count the work the checker did, so they’re the numbers to watch when one file is suspiciously slow. The rest of this lesson greps these lines out to compare runs.

TypeScript 7 prints a shorter report than earlier versions. It drops the separate “Lines of Library” and “Lines of TypeScript” counts and the I/O and module-resolution timings. Its Types count also depends on how many checkers ran, as the next section shows, so don’t compare a Types number from TypeScript 6 with one from 7.

TypeScript 7 vs TypeScript 6

TypeScript 7 is a rewrite of the compiler in Go, and it compiles to a native binary instead of running on Node. To see what that’s worth here, check the same project with TypeScript 6. npx -p typescript@6 runs it once without touching the project’s own typescript:

$ rm -rf dist
$ npx -p typescript@6 tsc --extendedDiagnostics | grep -E "^(Types|Check time|Emit time|Total time)"
Types:                       31548
Check time:                  0.74s
Emit time:                   0.30s
Total time:                  1.46s
$ rm -rf dist
$ npx tsc --extendedDiagnostics | grep -E "^(Types|Check time|Emit time|Total time)"
Types:            45047
Check time:      0.433s
Emit time:       0.074s
Total time:      0.598s

TypeScript 7 did the same build in 0.60s instead of 1.46s, about 2.4 times faster. Across three more runs each, TypeScript 6 took 1.53s to 2.06s and TypeScript 7 took 0.64s to 0.71s. That’s a real gain, but well short of the roughly 10x speedups reported for large codebases. This project is small enough that both compilers finish in a second or two, so measure your own before expecting a particular number.

TypeScript 7 can also check files in parallel. --checkers sets how many type checkers it runs, and --singleThreaded turns parallelism off entirely:

$ npx tsc --noEmit --extendedDiagnostics --checkers 1 | grep -E "^(Check|Total) time"
Check time:     0.389s
Total time:     0.453s
$ npx tsc --noEmit --extendedDiagnostics --checkers 4 | grep -E "^(Check|Total) time"
Check time:      0.420s
Total time:      0.488s
$ npx tsc --noEmit --extendedDiagnostics --checkers 8 | grep -E "^(Check|Total) time"
Check time:      0.538s
Total time:      0.610s
$ npx tsc --noEmit --extendedDiagnostics --singleThreaded | grep -E "^(Check|Total) time"
Check time:     0.357s
Total time:     0.450s

More checkers made this project slower, not faster. Each checker builds its own copy of the types its files need, and here every module imports the one before it, so every checker ends up building most of the chain. The counters show it:

$ npx tsc --noEmit --extendedDiagnostics --checkers 1 | grep -E "^(Types|Instantiations|Memory used)"
Types:           31900
Instantiations:  27046
Memory used:    74839K
$ npx tsc --noEmit --extendedDiagnostics --checkers 8 | grep -E "^(Types|Instantiations|Memory used)"
Types:            59218
Instantiations:   28322
Memory used:    144382K

Eight checkers built almost twice as many types and used almost twice the memory to check the same code. That’s also why the Types number in a default run (45,047) sits in between, and why a single checker’s 31,900 is close to TypeScript 6’s 31,548. A wider version of the project, 2,000 modules that don’t import each other, showed no consistent difference between 1 and 12 checkers either: every run finished in under a second, and the noise between runs was bigger than the effect. Parallel checking pays off on large projects whose files are mostly independent. Leave --checkers at its default unless a measurement on your own codebase says otherwise.

Incremental Builds

Without incremental, every run redoes all the work. Here is the same project built a second time, unchanged:

$ npx tsc --extendedDiagnostics | grep -E "^(Check|Emit|Total) time"
Check time:      0.553s
Emit time:       0.060s
Total time:      0.688s

Add two options to the tsconfig. incremental saves a .tsbuildinfo file that records a hash of each file and, once it knows them, the shapes of each file’s exports. tsBuildInfoFile says where to put it.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo"
  },
  "include": ["src/**/*"]
}
$ rm -rf dist
$ npx tsc --extendedDiagnostics | grep -E "^(Check|Emit|Total) time"
Check time:            0.638s
Emit time:             0.134s
Total time:            0.878s
$ npx tsc --extendedDiagnostics | grep -E "^(Check|Emit|Total) time"
Total time:           0.097s

The first incremental build took 0.88s, against 0.62s and 0.69s for the plain builds. It does extra work writing the cache, but a single run’s gap this size is close to run-to-run noise. The second run has no Check time or Emit time line at all. tsc read .tsbuildinfo, found every file hash unchanged, and skipped both phases. The whole run took under a tenth of a second, because the native compiler has no Node startup to pay for.

Now edit a file. In src/mod0.ts, change tags: [String(n)] to tags: [`#${n}`] (the body changes, the exported types don’t), rebuild, then make a second body-only edit ([`id-${n}`]) and rebuild again:

$ npx tsc --extendedDiagnostics --listEmittedFiles > run3.txt
$ grep -c TSFILE run3.txt
402
$ grep -E "^(Check|Emit|Total) time" run3.txt
Check time:           1.169s
Emit time:            0.087s
Total time:           1.400s
$ npx tsc --extendedDiagnostics --listEmittedFiles > run4.txt
$ grep TSFILE run4.txt
TSFILE: C:/tmp/tsp7/inc/dist/mod0.js
TSFILE: C:/tmp/tsp7/inc/.tsbuildinfo
$ grep -E "^(Check|Emit|Total) time" run4.txt
Emit time:            0.005s
Total time:           0.120s

The first edit re-emitted all 401 JavaScript files, but the second emitted only mod0.js. Without declaration, a cold build records each file’s hash but not its export shape. So on the first change, tsc can’t tell whether mod0’s exports changed, and it redoes every file that depends on mod0, all the way down the chain. That first edit was the slowest build in this section: 1.17s of checking, more than the cold build (a repeat run took 1.70s). Once the shapes are recorded, a body-only edit stays local: the second edit has no Check time line at all.

With declaration on (or composite, which implies it), the shapes are recorded on the cold build. Start from scratch with --declaration, make one more body-only edit to mod0.ts, and count the emitted files:

$ rm -rf dist .tsbuildinfo
$ npx tsc --declaration
$ npx tsc --declaration --listEmittedFiles | grep -c TSFILE
3

Even the first edit stayed local. The three files are mod0.js, mod0.d.ts and .tsbuildinfo. An edit that does change an exported type still re-checks everything that depends on it, which is correct.

.tsbuildinfo describes the outputs it last wrote, and tsc trusts it. Delete dist/ but keep the cache, and nothing is rebuilt:

$ rm -rf dist
$ npx tsc --listEmittedFiles | grep -c TSFILE
0
$ ls dist
ls: cannot access 'dist': No such file or directory

So treat the cache and the output as a pair. Clean them together, or delete .tsbuildinfo to force a full build. The cache is a build artifact like dist/, so add .tsbuildinfo to .gitignore and don’t commit it. CI restores it from a cache instead, as shown in Type Checking in CI.

Project References

Project References split a codebase into packages that tsc --build compiles separately, in dependency order. A package that hasn’t changed is skipped entirely, and dependents read its .d.ts output instead of re-checking its source. A referenced package must set composite: true, which also turns on declaration.

packages/
├── shared/        # index.ts exports formatPrice(cents)
├── api/           # index.ts imports from "../shared"
└── web/           # index.ts imports from "../shared"
tsconfig.json      # root: references only, no source files
// packages/shared/index.ts
export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

// packages/api/index.ts
import { formatPrice } from "../shared";

console.log("api price:", formatPrice(1999));

// packages/web/index.ts
import { formatPrice } from "../shared";

console.log("web price:", formatPrice(500));

tsconfig.json files may contain comments, so the notes below are valid as written. First, packages/shared/tsconfig.json:

{
  "compilerOptions": {
    "composite": true,       // required: marks this as a referenced project
    "declaration": true,     // other packages consume the .d.ts files
    "declarationMap": true,  // go-to-definition jumps to the .ts source
    "outDir": "./dist"
  }
}

packages/api/tsconfig.json (packages/web/tsconfig.json is the same, minus the comment):

{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist"
  },
  "references": [
    { "path": "../shared" }  // build shared first if it is out of date
  ]
}

The root tsconfig.json is only a build entry point, with no source files of its own:

{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ]
}
$ npx tsc --build --verbose
11:00:14 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * packages/web/tsconfig.json
    * tsconfig.json

11:00:15 PM - Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/dist/tsconfig.tsbuildinfo' does not exist

11:00:15 PM - Building project 'packages/shared/tsconfig.json'...

11:00:15 PM - Project 'packages/api/tsconfig.json' is out of date because output file 'packages/api/dist/tsconfig.tsbuildinfo' does not exist

11:00:15 PM - Building project 'packages/api/tsconfig.json'...

11:00:15 PM - Project 'packages/web/tsconfig.json' is out of date because output file 'packages/web/dist/tsconfig.tsbuildinfo' does not exist

11:00:15 PM - Building project 'packages/web/tsconfig.json'...

tsc --build lists the projects in dependency order: shared first, because api and web both need its .d.ts files. The log is printed one project at a time, but api and web don’t depend on each other, and TypeScript 7 can build them at the same time (shown below). Each project gets its own dist/tsconfig.tsbuildinfo, so references are incremental per package.

Now change only the body of formatPrice, so it returns `$${(cents / 100).toFixed(2)} USD`, and build again:

$ npx tsc --build --verbose
11:00:19 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * packages/web/tsconfig.json
    * tsconfig.json

11:00:19 PM - Project 'packages/shared/tsconfig.json' is out of date because output 'packages/shared/dist/tsconfig.tsbuildinfo' is older than input 'packages/shared/index.ts'

11:00:19 PM - Building project 'packages/shared/tsconfig.json'...

11:00:19 PM - Project 'packages/api/tsconfig.json' is up to date with .d.ts files from its dependencies

11:00:19 PM - Updating output timestamps of project 'packages/api/tsconfig.json'...

11:00:19 PM - Project 'packages/web/tsconfig.json' is up to date with .d.ts files from its dependencies

11:00:19 PM - Updating output timestamps of project 'packages/web/tsconfig.json'...

Only shared was rebuilt. Its index.d.ts came out identical, so api and web are “up to date with .d.ts files from its dependencies”, and tsc only touched their output timestamps. Next, change the signature to formatPrice(cents: number, currency = "USD") and use ${currency} in place of the literal USD. That changes the .d.ts:

$ npx tsc --build --verbose
11:00:23 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * packages/web/tsconfig.json
    * tsconfig.json

11:00:23 PM - Project 'packages/shared/tsconfig.json' is out of date because output 'packages/shared/dist/tsconfig.tsbuildinfo' is older than input 'packages/shared/index.ts'

11:00:23 PM - Building project 'packages/shared/tsconfig.json'...

11:00:23 PM - Project 'packages/api/tsconfig.json' is out of date because output 'packages/api/dist/tsconfig.tsbuildinfo' is older than input 'packages/shared/dist/index.d.ts'

11:00:23 PM - Building project 'packages/api/tsconfig.json'...

11:00:23 PM - Project 'packages/web/tsconfig.json' is out of date because output 'packages/web/dist/tsconfig.tsbuildinfo' is older than input 'packages/shared/dist/index.d.ts'

11:00:23 PM - Building project 'packages/web/tsconfig.json'...

Now both dependents rebuild, and the reason names the file that changed: shared’s new index.d.ts. --clean deletes every output the build wrote, including the .tsbuildinfo files, but leaves the empty dist/ folders:

$ ls packages/api/dist
index.d.ts
index.js
tsconfig.tsbuildinfo
$ npx tsc --build --clean
$ ls -A packages/api/dist packages/shared/dist packages/web/dist
packages/api/dist:

packages/shared/dist:

packages/web/dist:

npx tsc --build --watch runs the same up-to-date checks on every save. These configs don’t set module, so TypeScript 7 uses its default, esnext, and the emitted api/dist/index.js still says import { formatPrice } from "../shared". Making that resolve to shared/dist at runtime is the job of your package manager’s workspaces and each package’s "type" and "exports" fields, not of tsc.

Building Projects in Parallel

api and web don’t depend on each other, so TypeScript 7 builds them at the same time. --extendedDiagnostics prints a Total time line as each project finishes (shared, then the other two), then totals for the whole build. Compare the default with --builders 1, which builds one project at a time. Each run starts from the clean state above, with npx tsc --build --clean in between:

$ npx tsc --build --extendedDiagnostics | grep -E "^(Total time|Aggregate (Check|Total) time)"
Total time:           0.231s
Total time:            0.465s
Total time:            0.472s
Aggregate Check time:            0.595s
Aggregate Total time:            0.522s
$ npx tsc --build --clean
$ npx tsc --build --extendedDiagnostics --builders 1 | grep -E "^(Total time|Aggregate (Check|Total) time)"
Total time:           0.219s
Total time:           0.419s
Total time:           0.613s
Aggregate Check time:            0.506s
Aggregate Total time:            0.633s

By default, the last two projects finished 7ms apart, and the three checks added up to 0.595s inside a build that took 0.522s in total. That’s only possible if the checks overlapped. With --builders 1, each project finished about 0.2s after the one before, which is how TypeScript 6 and earlier always built. Three tiny packages gain about a tenth of a second here. In a monorepo with many independent packages, the gain is larger.

skipLibCheck

By default tsc type-checks every .d.ts file it loads, including everything under node_modules/@types. This small project depends on two common type packages:

{
  "name": "skiplib-demo",
  "private": true,
  "devDependencies": {
    "@types/node": "24.x",
    "@types/react": "19.x",
    "typescript": "7.0.2"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "strict": true,
    "types": ["node"],
    "noEmit": true
  },
  "include": ["src/**/*"]
}

And src/index.ts:

import { readFileSync } from "node:fs";
import type { ReactNode } from "react";

const label: ReactNode = "hello";
console.log(label, readFileSync("package.json", "utf8").length);

"types": ["node"] is needed since TypeScript 6, which stopped loading every @types package automatically. Without it, this file fails with error TS2591: Cannot find name 'node:fs'. react doesn’t need listing, because the file imports it by name. After npm install, which resolved @types/node@24.19.2 and @types/react@19.3.0:

$ npx tsc --extendedDiagnostics | grep -E "^(Files|Lines|Check time|Total time)"
Files:              178
Lines:           143963
Check time:      0.390s
Total time:      0.493s
$ npx tsc --extendedDiagnostics --skipLibCheck | grep -E "^(Files|Lines|Check time|Total time)"
Files:             178
Lines:          143963
Check time:     0.002s
Total time:     0.090s

The five lines of index.ts cost almost nothing. Nearly all of the 0.39s check went into the declaration files, about 144,000 lines counting the standard library, which you can’t fix anyway. With skipLibCheck the same 178 files are still loaded and their types are still used, but they aren’t checked for internal errors. To make it permanent, add "skipLibCheck": true to compilerOptions. How much it saves depends on how many dependencies you have. Here it took the check time to almost nothing and the total down by more than 80%. TypeScript 6, run the same way, went from 2.71s to 0.84s, so the flag matters on both compilers. The trade-off is that it skips every .d.ts file, including ones you wrote by hand (Practice 3 shows a typo slipping through).

isolatedModules

isolatedModules makes tsc reject code that a single-file transpiler such as esbuild or SWC couldn’t compile correctly without looking at other files. With that guaranteed, you can split the work in two: a fast transpiler produces the JavaScript, and tsc --noEmit only checks types. This uses the 401-file project from the first example, after node gen.mjs and npm install -D typescript esbuild:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src/**/*"]
}
{
  "name": "iso-demo",
  "private": true,
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js"
  }
}
$ npm run build

> build
> esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js


  dist\index.js  49.2kb

Done in 129ms
$ node dist/index.js
402
$ npx tsc --noEmit --extendedDiagnostics | grep -E "^(Check|Total) time"
Check time:      0.620s
Total time:      0.754s

esbuild turned 401 files into one runnable bundle in 129ms. tsc took 0.75s just to check them, without writing anything. Transpiling a file is still much less work than checking it. The catch is that esbuild never checks types. Put a type error in src/index.ts:

import { make399 } from "./mod399";
const count: number = "not a number";
console.log(Object.keys(make399(1)).length, count);
$ npm run build

> build
> esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js


  dist\index.js  49.3kb

Done in 160ms
$ node dist/index.js
402 not a number
$ npm run typecheck

> typecheck
> tsc --noEmit

src/index.ts(2,7): error TS2322: Type 'string' is not assignable to type 'number'.

The bundle builds and runs with a string in a number variable. Only npm run typecheck catches it, so the type check has to run somewhere, in the editor, in a pre-push hook or in CI. Practice 5 shows the kind of code isolatedModules rejects.

Profiling Slow Type Checks

This src/codes.ts is deliberately expensive. It enumerates 30,000 codes as string literals and builds an object type with one property per code:

type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type Region = "eu" | "us" | "ap";

// 3 x 10 x 10 x 10 x 10 = 30,000 string literal members
export type Code = `${Region}-${Digit}${Digit}${Digit}${Digit}`;

// Distributes over every member: one conditional type each
type Tagged<T extends string> = T extends `eu-${string}` ? `EU:${T}` : `XX:${T}`;

// An object type with one property per code: 30,000 properties
export type Labels = { [C in Code]: Tagged<C> };

export function label(c: Code, table: Labels): string {
  const loose: Record<Code, string> = table; // compares all 30,000 properties
  return loose[c];
}

src/index.ts calls it, and tsconfig.json is a plain no-emit config:

import { label, type Labels } from "./codes";

const table = new Proxy({} as Labels, {
  get: (_t, code: string) => (code.startsWith("eu-") ? "EU:" : "XX:") + code,
});
console.log(label("eu-0042", table), label("us-1999", table));
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "noEmit": true
  },
  "include": ["src/**/*"]
}

--generateTrace records how long the checker spent on each file, declaration and type comparison, and @typescript/analyze-trace (npm install -D @typescript/analyze-trace) summarizes that trace:

$ npx tsc --extendedDiagnostics | grep -E "^(Lines|Types|Check time|Total time)"
Lines:            56168
Types:           138642
Check time:      0.352s
Total time:      0.447s
$ npx tsc --generateTrace trace
$ ls trace
legend.json
trace.json
types_0.json
types_1.json
types_2.json
types_3.json
$ npx analyze-trace trace | wc -l
30012
$ npx analyze-trace trace --expandTypes false --color false
Analyzed C:/tmp/tsp7/trace/tsconfig.json (trace.json)
Hot Spots
├─ Check file C:\tmp\tsp7\trace\src\codes.ts (364ms)
│  └─ Check variable declaration from (line 14, char 9) to (line 14, char 44) (276ms)
│     └─ Compare types 34663 and 34670 (243ms)
└─ Check file C:\tmp\tsp7\trace\node_modules\@typescript\typescript-win32-x64\lib\lib.dom.d.ts (353ms)

No duplicate packages found

A 16-line file pushed the type count to 138,642. That ratio is the first warning sign. TypeScript 7 writes one types_N.json file per checker (four here), and analyze-trace reads them all. The plain analyze-trace run is 30,012 lines long, because it prints every one of the 30,000 union members involved in the slow comparison. --expandTypes false stops that. The report points at line 14, the Record<Code, string> assignment, where comparing two 30,000-property types took most of the file’s time. It also shows something unexpected: lib.dom.d.ts cost 353ms, because this Node-only project still loads and checks the browser library by default. skipLibCheck removes that cost. On a project with no single slow spot, add --forceMillis 100 to report anything over 100ms. To browse the whole timeline, load trace/trace.json into about://tracing in Chrome or into Perfetto.

Deleting line 14 would remove only one comparison. Tagged and Labels would still have to be built over 30,000 literals. The real fix is to stop enumerating the codes. Use a pattern template literal instead, in a new src/codes.ts:

type Region = "eu" | "us" | "ap";

// A pattern, not a list: matches "eu-0042" without enumerating 30,000 codes
export type Code = `${Region}-${string}`;

// Distributes over 3 patterns (one per region) instead of 30,000 literals
type Tagged<T extends string> = T extends `eu-${string}` ? `EU:${T}` : `XX:${T}`;

// One index signature per region pattern, not one property per code
export type Labels = { [C in Code]: Tagged<C> };

export function label(c: Code, table: Labels): string {
  const loose: Record<Code, string> = table; // now compares 3 index signatures
  return loose[c];
}
$ npx tsc --extendedDiagnostics | grep -E "^(Lines|Types|Check time|Total time)"
Lines:           56167
Types:           31952
Check time:     0.259s
Total time:     0.337s
$ npx tsc --extendedDiagnostics --skipLibCheck | grep -E "^(Types|Check time|Total time)"
Types:             446
Check time:     0.003s
Total time:     0.087s
$ npx esbuild src/index.ts --bundle --platform=node --log-level=warning --outfile=out/index.js
$ node out/index.js
EU:eu-0042 XX:us-1999

The last two commands run it with esbuild (npm install -D esbuild, as in the previous section). The pattern cut the type count from 138,642 to 31,952 and the check time from 0.35s to 0.26s, and the program still runs. Nearly all of the remaining 31,952 types come from the default libraries, so adding skipLibCheck leaves 446 types and a 0.003s check.

The cost is precision. Type-check one line, const x: Code = "eu-banana";, against each version. banana.ts imports the new Code from ./src/codes on its first line, and banana2.ts imports a copy of the old one. Both sit next to tsconfig.json, so TypeScript 7 refuses to check a single named file without --ignoreConfig:

$ npx tsc --noEmit banana.ts
error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.
$ npx tsc --noEmit --ignoreConfig banana.ts
$ npx tsc --noEmit --ignoreConfig banana2.ts
banana2.ts(2,7): error TS2322: Type '"eu-banana"' is not assignable to type '"ap-0000" | "ap-0001" | "ap-0002" | "ap-0003" | "ap-0004" | "ap-0005" | "ap-0006" | "ap-0007" | "ap-0008" | "ap-0009" | "ap-0010" | "ap-0011" | "ap-0012" | "ap-0013" | "ap-0014" | ... 29984 more ... | "us-9999"'.

The pattern accepts any string after eu-, while the enumerated union rejected it. (TypeScript 7 also sorts union members in messages, so the list starts at "ap-0000" instead of following the order the type was written in.) That trade is usually worth it when the list is generated, and worth thinking about when the exact values matter.

Avoiding Slow Type Patterns

The profiling example showed the main lesson: the checker’s cost follows how many types you make it build. These are the patterns that usually show up in a trace. Each example below was type-checked by passing the file straight to tsc, in a folder with no tsconfig.json (with one present, add --ignoreConfig, as the profiling example did).

A template literal type is the same union, written shorter. Writing routes as a template instead of a hand-written union ("/users" | "/users/:id" | "/posts" | ...) saves typing, but the checker still expands it into every combination. routes.ts shows this:

type ResourceId = "users" | "posts" | "comments";
type Route = `/${ResourceId}` | `/${ResourceId}/:id`;

const handlers = {
  "/users": "list users",
} satisfies Record<Route, string>;

console.log(handlers);
$ npx tsc --noEmit routes.ts
routes.ts(6,3): error TS2739: Type '{ "/users": string; }' is missing the following properties from type 'Record<Route, string>': "/comments", "/comments/:id", "/posts", "/posts/:id", "/users/:id"

The error lists the five missing routes as concrete literals, so Route is an ordinary six-member union, expanded up front. Three resources make that cheap. Template literals over larger unions multiply, though: the Code type in the profiling example was 3 × 10⁴ = 30,000 members from 13 literals. When the product gets large, switch to a pattern like `/${string}`, as the profiling fix did.

Recursive types have a depth limit, and hitting it is an error. recursion.ts builds the same 100-element tuple in two ways:

// Not tail-recursive: the recursive call sits inside a tuple spread
type Repeat<T, N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? [] : [T, ...Repeat<T, N, [...Acc, T]>];

// Tail-recursive: the recursive call is the whole branch
type RepeatTail<T, N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : RepeatTail<T, N, [...Acc, T]>;

type A = Repeat<"x", 100>["length"];
type B = RepeatTail<"x", 100>["length"];

const a: A = 100;
const b: B = 100;
console.log(a, b);
$ npx tsc --noEmit recursion.ts
recursion.ts(9,10): error TS2589: Type instantiation is excessively deep and possibly infinite.

Only line 9 fails. TS2589 is the checker giving up on work that grows with depth. Repeat keeps working after its recursive call (it spreads the result into a tuple), so it hits the limit at 100 repetitions. RepeatTail’s recursive call is the whole result, and tail-recursive types like that are allowed about 1,000 levels (999 type-checks, 1,000 hits the same error).

When a utility type recurses over user data, you can cap the depth yourself. The DeepReadonly in deep.ts counts down through a tuple lookup ([-1, 0, 1, 2, 3, 4][2] is 1) and stops at zero:

type DeepReadonly<T, Depth extends number = 2> = Depth extends 0
  ? T
  : { readonly [K in keyof T]: T[K] extends object
      ? DeepReadonly<T[K], [-1, 0, 1, 2, 3, 4][Depth]>
      : T[K] };

interface Config {
  server: { tls: { cert: { path: string } } };
}

const cfg: DeepReadonly<Config> = { server: { tls: { cert: { path: "a.pem" } } } };

cfg.server = { tls: { cert: { path: "b.pem" } } }; // depth 1: readonly
cfg.server.tls = { cert: { path: "c.pem" } };      // depth 2: readonly
cfg.server.tls.cert = { path: "d.pem" };           // depth 3: past the limit
console.log(cfg.server.tls.cert.path);
$ npx tsc --noEmit deep.ts
deep.ts(13,5): error TS2540: Cannot assign to 'server' because it is a read-only property.
deep.ts(14,12): error TS2540: Cannot assign to 'tls' because it is a read-only property.

Lines 13 and 14 are rejected and line 15 compiles, so the type really does stop at depth 2. A cap trades completeness for a known bound on the work.

Prefer interface extends to intersections for object types. The TypeScript team’s performance guide recommends interfaces because they form a single flat type whose relationships the checker caches, while an intersection is re-merged property by property. entity.ts shows the difference in how each one treats a conflict:

interface BaseEntity {
  id: number;
}

// Intersection: conflicting `id` types silently become `never`
type UserT = BaseEntity & { id: string; name: string };

// Interface: the same conflict is reported where it is declared
interface UserI extends BaseEntity {
  id: string;
  name: string;
}

const t: UserT = { id: 1, name: "Ada" };
console.log(t);
$ npx tsc --noEmit entity.ts
entity.ts(9,11): error TS2430: Interface 'UserI' incorrectly extends interface 'BaseEntity'.
  Types of property 'id' are incompatible.
    Type 'string' is not assignable to type 'number'.
entity.ts(14,20): error TS2322: Type 'number' is not assignable to type 'never'.

The interface is checked once, where it’s declared (line 9). The intersection is accepted as written, and its id quietly becomes never, which only surfaces at line 14 when you try to use it.

Barrel files cost what they import, whichever re-export style they use. Three modules sit behind two barrels, star.ts with export * and named.ts with explicit re-exports. Three entry files each import UserService a different way:

// users.ts
export interface User { id: number; name: string }
export class UserService {
  find(id: number): User {
    return { id, name: "Ada" };
  }
}

// posts.ts
export interface Post { id: number; title: string }
export class PostService {}

// comments.ts
export interface Comment { id: number; body: string }
export class CommentService {}

// star.ts
export * from "./users";
export * from "./posts";
export * from "./comments";

// named.ts
export { UserService, type User } from "./users";
export { PostService, type Post } from "./posts";
export { CommentService, type Comment } from "./comments";

// app-star.ts
import { UserService } from "./star";
console.log(new UserService().find(1).name);

// app-named.ts
import { UserService } from "./named";
console.log(new UserService().find(1).name);

// app-direct.ts
import { UserService } from "./users";
console.log(new UserService().find(1).name);

--listFiles prints every file in the program. TypeScript 7 keeps its default lib files in a platform package (node_modules/@typescript/typescript-win32-x64/lib/ on Windows), and the grep -v hides them:

$ npx tsc --noEmit --listFiles app-star.ts | grep -v "/lib/lib\."
C:/tmp/tsp7/barrel/users.ts
C:/tmp/tsp7/barrel/posts.ts
C:/tmp/tsp7/barrel/comments.ts
C:/tmp/tsp7/barrel/star.ts
C:/tmp/tsp7/barrel/app-star.ts
$ npx tsc --noEmit --listFiles app-named.ts | grep -v "/lib/lib\."
C:/tmp/tsp7/barrel/users.ts
C:/tmp/tsp7/barrel/posts.ts
C:/tmp/tsp7/barrel/comments.ts
C:/tmp/tsp7/barrel/named.ts
C:/tmp/tsp7/barrel/app-named.ts

Explicit re-exports load exactly the same files as export *. A barrel names its modules either way, so the compiler loads all of them. Only importing from ./users directly avoids that:

$ npx tsc --noEmit --listFiles app-direct.ts | grep -v "/lib/lib\."
C:/tmp/tsp7/barrel/users.ts
C:/tmp/tsp7/barrel/app-direct.ts

A full tsc build that includes the whole src/ loads every file anyway. Barrels cost the most in tools that start from one file, such as the editor opening a file, a test runner loading one test, or tsc given a single entry point. In those cases, a large barrel pulls in the whole folder. Explicit re-exports are still worth writing, but for a different reason: type User keeps the re-export valid under isolatedModules (Practice 5).

Type Checking in CI

In CI, run the type check and the build as separate jobs so they run side by side, and keep the type checker’s .tsbuildinfo between runs with a cache instead of committing it. This workflow restores the most recent cache for the branch, so tsc only re-checks what changed since then:

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - uses: actions/cache@v4
        with:
          path: .tsbuildinfo
          key: tsbuildinfo-${{ github.ref_name }}-${{ github.sha }}
          restore-keys: |
            tsbuildinfo-${{ github.ref_name }}-
            tsbuildinfo-
      - run: npx tsc --noEmit  # tsconfig sets incremental + tsBuildInfoFile

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build

This workflow is an example and wasn’t run for this lesson. What was measured is the local rehearsal below. A cache key can only be saved once, so each run saves under its commit’s SHA, and restore-keys falls back to the newest cache for the branch, then for any branch. Only the --noEmit job is cached. As the Incremental Builds section showed, restoring .tsbuildinfo without the dist/ it describes makes an emitting build skip its output.

You can rehearse what CI does locally. The 401 generated files go in checkout1/ with this tsconfig. Then a fresh copy of the sources in checkout2/ plays the next CI run, which starts with no cache:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "noEmit": true,
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo"
  },
  "include": ["src/**/*"]
}
$ cd checkout1
$ npx tsc --extendedDiagnostics | grep -E "^(Check|Total) time"
Check time:            0.529s
Total time:            0.628s
$ mkdir -p ../cache && cp .tsbuildinfo ../cache/
$ cd ../checkout2
$ npx tsc --extendedDiagnostics | grep -E "^(Check|Total) time"
Check time:            0.508s
Total time:            0.632s
$ rm .tsbuildinfo
$ cp ../cache/.tsbuildinfo .
$ npx tsc --extendedDiagnostics | grep -E "^(Check|Total) time"
Total time:           0.105s

--noEmit and incremental together still write .tsbuildinfo. Without the cache, the fresh checkout pays the full 0.63s. With the restored cache, the same checkout skips checking entirely. The copied files have new timestamps, but that doesn’t matter, because plain tsc compares file hashes, not dates. (tsc --build checks timestamps first, as the Project References output showed.) This is why the cache file belongs in .gitignore and in a CI cache: committing it adds a generated file to the repository that changes on almost every commit.

strict Mode Performance Impact

You may see advice to turn off some strict flags for speed. Measure before you do. Here is the 401-file project from the isolatedModules section, checked twice with strict on and twice with it off:

$ npx tsc --extendedDiagnostics | grep -E "^(Types|Check time)"
Types:            45047
Check time:      0.530s
$ npx tsc --extendedDiagnostics --strict false | grep -E "^(Types|Check time)"
Types:            41334
Check time:      0.569s
$ npx tsc --extendedDiagnostics | grep -E "^(Types|Check time)"
Types:            45047
Check time:      0.533s
$ npx tsc --extendedDiagnostics --strict false | grep -E "^(Types|Check time)"
Types:            41334
Check time:      0.635s

Turning strict off saves about 3,700 types, and the check doesn’t get faster: 0.53s both times with it, 0.57s and 0.64s without, which is within run-to-run noise. Disabling a flag such as strictFunctionTypes would also lose real safety for no measurable gain. Only do it if a trace shows one specific check is the hot spot.

Module Resolution Performance

moduleResolution decides how an import string becomes a file path, and every candidate it tries is a file system lookup. --traceResolution logs each one. On the skipLibCheck project above (@types/node and @types/react), start with the mode older guides default to, node10:

$ npx tsc --module commonjs --moduleResolution node10
tsconfig.json(2,3): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.

TypeScript 6 deprecated node10 (also spelled node) and TypeScript 7 removed it, so two modes are left. Count the failed lookups for each:

$ npx tsc --module esnext --moduleResolution bundler --traceResolution | grep -c "does not exist"
3026
$ npx tsc --module nodenext --moduleResolution nodenext --traceResolution | grep -c "does not exist"
3026

Both modes made exactly the same 3,026 failed lookups. Nearly all of them happen inside @types/node, whose files import each other through node_modules, and that search works the same way in both modes. The 401-file project from the isolatedModules section, whose imports are all relative, has no failed lookups at all, because every ./mod398 is found on the first try:

$ npx tsc --module esnext --moduleResolution bundler --traceResolution | grep -c "does not exist"
0
$ npx tsc --module nodenext --moduleResolution nodenext --traceResolution | grep -c "does not exist"
0

So pick moduleResolution for correctness: bundler when Vite, webpack or esbuild resolves your imports, nodenext when Node runs the output directly. Don’t pick it for speed. TypeScript 7’s --extendedDiagnostics no longer reports resolution time on its own line, so --traceResolution is the tool to reach for: look for imports that search many directories before they’re found.

Practical Checklist

For a project that compiles slowly, in this order:

  1. Run tsc --extendedDiagnostics and note Check time, Types and Instantiations. Every later step is judged against these numbers. If you’re still on TypeScript 6 or earlier, compare with npx tsc from typescript@7 before changing anything else; on the project here it cut the total by more than half.
  2. Turn on skipLibCheck and incremental, and add .tsbuildinfo to .gitignore.
  3. If Types looks too large for the code, run tsc --generateTrace and npx analyze-trace --expandTypes false. Use --forceMillis to lower its threshold on a small project.
  4. In the hot files, look for big unions built from template literals or mapped types, and replace enumerations with patterns where the exact values don’t matter.
  5. If the codebase is really several packages, split it with Project References (composite) so unchanged packages are skipped.
  6. Hand transpiling to esbuild or SWC with isolatedModules: true, and keep tsc --noEmit as a separate check.
  7. In CI, restore .tsbuildinfo from a cache for the type-check job instead of committing it.

Practice

The solutions were run with TypeScript 7.0.2 on Node v24.18.1. The timestamps and absolute paths in your output will differ.

  1. Make a three-file project to see what incremental actually skips. src/math.ts exports double(n), src/index.ts imports and calls it, and src/other.ts exports an unrelated constant. Use the incremental config from this lesson, plus outDir: "./dist", rootDir: "./src" and include: ["src/**/*"]. Build twice with npx tsc --listEmittedFiles, then change only the body of double from n * 2 to n + n and build a third time.
Solution
{
  "compilerOptions": {
    "module": "commonjs",
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  },
  "include": ["src/**/*"]
}

Keep "module": "commonjs" from the lesson’s config. Without it, TypeScript 7 emits import statements (its default is esnext), and node dist/index.js fails with SyntaxError: Cannot use import statement outside a module in a folder whose package.json isn’t "type": "module".

// src/math.ts
export function double(n: number): number {
  return n * 2;
}

// src/index.ts
import { double } from "./math";

console.log(double(21));

// src/other.ts
export const greeting = "unrelated file";
$ npx tsc --listEmittedFiles
TSFILE: C:/tmp/tsp7/pinc/dist/math.js
TSFILE: C:/tmp/tsp7/pinc/dist/index.js
TSFILE: C:/tmp/tsp7/pinc/dist/other.js
TSFILE: C:/tmp/tsp7/pinc/.tsbuildinfo
$ npx tsc --listEmittedFiles

After changing n * 2 to n + n:

$ npx tsc --listEmittedFiles
TSFILE: C:/tmp/tsp7/pinc/dist/math.js
TSFILE: C:/tmp/tsp7/pinc/dist/index.js
TSFILE: C:/tmp/tsp7/pinc/.tsbuildinfo
$ node dist/index.js
42

With nothing changed, the second build emits nothing at all. After the edit, tsc rebuilds math.ts and index.ts, which imports it, but leaves other.js alone, just as the lesson describes.

  1. Build the shared and api packages from the Project References section, leaving out web. shared/index.ts exports a formatPrice(cents) function and api/index.ts imports it from "../shared". Run npx tsc --build --verbose twice. Then edit only api/index.ts and run it a third time. Last, delete "composite": true from shared/tsconfig.json and run npx tsc --build --verbose. Then delete both dist/ folders and run npx tsc --build once more.
Solution
// packages/shared/index.ts
export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

// packages/api/index.ts
import { formatPrice } from "../shared";

console.log("api price:", formatPrice(1999));
$ npx tsc --build --verbose
10:59:13 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * tsconfig.json

10:59:13 PM - Project 'packages/shared/tsconfig.json' is out of date because output file 'packages/shared/dist/tsconfig.tsbuildinfo' does not exist

10:59:13 PM - Building project 'packages/shared/tsconfig.json'...

10:59:13 PM - Project 'packages/api/tsconfig.json' is out of date because output file 'packages/api/dist/tsconfig.tsbuildinfo' does not exist

10:59:13 PM - Building project 'packages/api/tsconfig.json'...

$ npx tsc --build --verbose
10:59:18 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * tsconfig.json

10:59:18 PM - Project 'packages/shared/tsconfig.json' is up to date because newest input 'packages/shared/index.ts' is older than output 'packages/shared/dist/tsconfig.tsbuildinfo'

10:59:18 PM - Project 'packages/api/tsconfig.json' is up to date because newest input 'packages/api/index.ts' is older than output 'packages/api/dist/tsconfig.tsbuildinfo'

After changing formatPrice(1999) to formatPrice(2499) in api/index.ts:

$ npx tsc --build --verbose
10:59:22 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * tsconfig.json

10:59:22 PM - Project 'packages/shared/tsconfig.json' is up to date because newest input 'packages/shared/index.ts' is older than output 'packages/shared/dist/tsconfig.tsbuildinfo'

10:59:22 PM - Project 'packages/api/tsconfig.json' is out of date because output 'packages/api/dist/tsconfig.tsbuildinfo' is older than input 'packages/api/index.ts'

10:59:22 PM - Building project 'packages/api/tsconfig.json'...

After deleting "composite": true from shared:

$ npx tsc --build --verbose
10:59:26 PM - Projects in this build: 
    * packages/shared/tsconfig.json
    * packages/api/tsconfig.json
    * tsconfig.json

10:59:26 PM - Project 'packages/shared/tsconfig.json' is out of date because output 'packages/shared/dist/index.js' is older than input 'packages/shared/tsconfig.json'

10:59:26 PM - Building project 'packages/shared/tsconfig.json'...

10:59:26 PM - Project 'packages/api/tsconfig.json' is up to date with .d.ts files from its dependencies

10:59:26 PM - Updating output timestamps of project 'packages/api/tsconfig.json'...

$ rm -rf packages/shared/dist packages/api/dist
$ npx tsc --build
packages/api/tsconfig.json(7,5): error TS6306: Referenced project 'C:/tmp/tsp7/prefs/packages/shared' must have setting "composite": true.

--verbose explains every decision. shared builds before api because api references it, and once shared is up to date it is skipped entirely, even when api changes. Without composite, tsc won’t treat shared as a referenced project, but it only says so while it is building api. The first build after the edit rebuilt shared, found its .d.ts unchanged and skipped api, so the broken setup passed silently. Only a build that really compiles api, such as a clean one in CI, reports TS6306.

  1. Write a declaration file with a mistake in it, then see what skipLibCheck does with it. Save legacy.d.ts with the line declare function legacyFormat(value: number): Strin; (note the typo) next to a trivial app.ts. Type-check both files with and without --skipLibCheck.
Solution
// app.ts
const label: string = "total";
console.log(label);
$ npx tsc --noEmit app.ts legacy.d.ts
legacy.d.ts(1,47): error TS2552: Cannot find name 'Strin'. Did you mean 'String'?
$ npx tsc --noEmit --skipLibCheck app.ts legacy.d.ts && echo "no errors"
no errors

skipLibCheck skips every .d.ts file, not only the ones in node_modules, so this error disappears too. That’s where the speed comes from, and it’s also why a broken hand-written declaration file can slip through.

  1. Check that the template literal Route from “Avoiding Slow Type Patterns” produces the same members as the hand-written union. Assign "/comments/:id" and "/post" to two Route variables and type-check the file.
Solution
type ResourceId = "users" | "posts" | "comments";
type Route = `/${ResourceId}` | `/${ResourceId}/:id`;

const ok: Route = "/comments/:id";
const bad: Route = "/post";

console.log(ok, bad);
$ npx tsc --noEmit routes.ts
routes.ts(5,7): error TS2820: Type '"/post"' is not assignable to type 'Route'. Did you mean '"/posts"'?

Only line 5 fails, so "/comments/:id" is a valid member. The compiler expanded ResourceId into every combination and can even suggest the closest one.

  1. Combine isolatedModules with the explicit barrel re-export. In users.ts, export an interface User and a class UserService. In index.ts, re-export both without the type keyword: export { UserService, User } from "./users";. Type-check index.ts with and without --isolatedModules, then add type before User.
Solution
// users.ts
export interface User {
  id: number;
  name: string;
}

export class UserService {
  find(id: number): User {
    return { id, name: "Ada" };
  }
}
$ npx tsc --noEmit index.ts && echo "no errors"
no errors
$ npx tsc --noEmit --isolatedModules index.ts
index.ts(1,23): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.

After changing index.ts to export { UserService, type User } from "./users";:

$ npx tsc --noEmit --isolatedModules index.ts && echo "no errors"
no errors

A single-file transpiler can’t open users.ts to find out that User is only a type, so it can’t know whether to drop it from the JavaScript output. type User says so explicitly, which is why the lesson’s barrel example uses it.

Frequently Asked Questions

Why does my TypeScript project compile slowly?
Run tsc --extendedDiagnostics first: it splits the time into parse, check and emit and counts the types the checker built. Common causes are type-checking large dependency declaration files (fixed by skipLibCheck), rebuilding everything on every run (fixed by incremental), and unions or mapped types that expand into tens of thousands of types, which tsc --generateTrace and @typescript/analyze-trace can pinpoint.
What is incremental compilation in TypeScript?
With incremental: true in tsconfig.json, tsc saves a .tsbuildinfo file that records a hash of every input file. On the next run it skips checking and emitting entirely if nothing changed, and otherwise redoes only the changed files and the files affected by them. Turning on declaration or composite records each file's export shape on the first build, so later body-only edits stay local.
Does tsc --build compile Project References in parallel?
In TypeScript 7, yes. Projects that don't depend on each other are built at the same time, and --builders sets how many run at once (--builders 1 turns it off). TypeScript 6 and earlier built one project at a time. Either way, most of the speedup comes from skipping projects that are up to date and from not rebuilding dependents when a dependency's .d.ts output didn't change.
Should I commit the .tsbuildinfo file?
No. .tsbuildinfo is a generated cache, like dist/, so add it to .gitignore. In CI, save and restore it with a cache step such as actions/cache. For an emitting build, keep it together with the output it describes, because tsc trusts it and won't re-emit files that are missing from dist/.
Is skipLibCheck safe to turn on?
For most projects, yes. Types from .d.ts files are still loaded and used, and tsc only skips checking those files for internal errors, which in dependencies you couldn't fix anyway. It skips every .d.ts file, including hand-written ones in your own source, so a typo in one of those won't be reported.