Appearance
3.7.1 — TypeScript: The Compiler & tsconfig
3.3 laid out what a type system buys — errors before running, machine-checked documentation, refactoring at scale. 3.6 then showed a language with none of it: JavaScript is dynamically and weakly typed, surfacing mistakes only at runtime. As JavaScript codebases grew from scripts into systems of hundreds of thousands of lines, that gap became the industry's most expensive problem. TypeScript (Microsoft, 2012) is the answer that won — JavaScript plus a static type layer, now the default for serious JavaScript work.
This folder covers the entire language. This first page covers the thing most tutorials skip and most production pain flows from: what the compiler actually is and does, and tsconfig.json — the file that decides how much safety you actually get. A team "using TypeScript" with a loose config is getting a fraction of the product; by the end of this page you'll know exactly which switches buy what.
1. The single most important fact: types are erased
TypeScript is erased. The compiler (tsc) type-checks your program, then produces plain JavaScript with every type annotation deleted. No types exist at runtime; the emitted code is what runs.
typescript
function greet(name: string): string {
return `Hello, ${name}`;
}javascript
function greet(name) {
return `Hello, ${name}`;
}This is type erasure, and three consequences follow that you must internalize:
- Zero runtime cost. No checks are inserted; TypeScript never slows your program. It's a linter with a proof system, not a runtime.
- Zero runtime protection. Data arriving from outside — an API response, a form,
JSON.parse— cannot be checked by TypeScript.const user: User = await res.json()is a claim, not a check; the object might be anything. 3.7.7 builds the professional answer (schema validation at boundaries). - It's a gradual type system (3.3): any
.jsis already valid.ts, so adoption can proceed file by file — the property that made adoption possible at all.
In 3.1's terms: tsc is a compiler front end — lexing, parsing, and semantic analysis (the type check) — that then, instead of generating machine code, strips annotations and emits JavaScript. Two sub-facts worth knowing: checking and emitting are independent (tsc emits JavaScript even when there are type errors unless noEmitOnError is set — types are advisory by design), and most modern toolchains split the jobs entirely: a fast transpiler strips types while tsc --noEmit runs as the checker (section 4).
One nuance: a few TypeScript features are not pure erasure — they generate code. enum emits a lookup object, class parameter properties emit assignments, and pre-2015 targets emit helper code. The modern trend (and the erasableSyntaxOnly flag, plus Node's built-in type-stripping) is to prefer the purely-erasable subset — types that vanish cleanly.
2. tsconfig.json: the safety dial
tsconfig.json marks the project root and configures everything. The high-order bits, annotated:
jsonc
{
"compilerOptions": {
/* ── How much checking — the part that actually matters ── */
"strict": true, // ← master switch for the strict family (section 3)
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined — honest indexing
"noImplicitOverride": true, // must write `override` when overriding
/* ── What JS to emit ── */
"target": "es2022", // syntax level of OUTPUT (what gets down-compiled)
"module": "esnext", // module syntax of OUTPUT (ESM vs CJS — 3.6.5)
"moduleResolution": "bundler", // HOW imports are located (node16 for pure Node)
"lib": ["es2022", "dom"], // which built-in APIs the checker believes exist
/* ── Project shape ── */
"rootDir": "src",
"outDir": "dist",
"declaration": true, // emit .d.ts type declarations (for libraries — 3.7.6)
"sourceMap": true, // map emitted JS back to TS for debugging
"paths": { "@app/*": ["src/*"] }, // import aliases (bundler must mirror them)
"esModuleInterop": true, // sane default-import interop with CJS
"skipLibCheck": true // don't re-check node_modules declarations (speed)
},
"include": ["src"]
}The mental grouping: checking flags decide how many bugs the compiler may catch; emit flags (target/module) decide what JavaScript comes out; resolution flags decide how import paths are found; the rest is project plumbing. Teams argue about the third group and neglect the first — exactly backwards.
3. The strict family: where the value lives
"strict": true enables a bundle of flags. Two of them are, by a wide margin, the product:
strictNullChecks — without it, null and undefined are assignable to every type, so the checker cannot help with JavaScript's single most common runtime error ("cannot read properties of undefined"). With it, string means a string; anything possibly absent must be typed string | null and narrowed before use (3.7.3):
typescript
function findUser(id: string): User | null { /* … */ }
const u = findUser("42");
console.log(u.name); // ❌ 'u' is possibly 'null'
if (u) console.log(u.name); // ✅ narrowed — the whole bug class, made compile-timeTony Hoare called null references his "billion-dollar mistake"; this flag is the practical refund.
noImplicitAny — without it, anything the compiler can't infer silently becomes any (checking off — 3.7.2), and any spreads through everything it touches. With it, unannotated ambiguity is an error: you're forced to say what you mean.
The rest of the family, briefly: strictFunctionTypes (sound parameter variance — 3.7.4), strictBindCallApply (typed call/bind — 3.6.3), strictPropertyInitialization (class fields definitely assigned), noImplicitThis, useUnknownInCatchVariables (catch (e) is unknown, not any — honest, since anything can be thrown). Beyond strict live optional hardeners worth adopting on new code: noUncheckedIndexedAccess (indexing may miss), exactOptionalPropertyTypes, noFallthroughCasesInSwitch.
The migration reality: on an existing codebase you can't flip everything at once. The playbook is a ratchet: start strict: false only if forced, enable per-flag, per-directory (project references or overrides), forbid new violations via lint/CI, burn down the old ones, and never loosen a flag once green. A codebase's real safety level is its tsconfig — reading it tells you more than reading the code.
4. Who actually runs your TypeScript
tsc is no longer the only — or usual — executor. The ecosystem split the two jobs (check vs strip-and-run):
| Tool | What it does | Where it fits |
|---|---|---|
tsc | full check + emit | the checker; libraries emitting .d.ts |
tsc --noEmit | check only | CI / editor truth — the standard gate |
| esbuild / swc | strip types without checking, very fast | bundlers (Vite), build pipelines |
tsx / ts-node | run .ts directly in Node (strip on the fly) | scripts, dev servers |
| Node ≥ 22.6 | built-in type stripping (erasable syntax only) | running TS with zero tooling |
Babel @babel/preset-typescript | strip during transpile | legacy Babel pipelines |
The architecture that follows, and that every serious repo converges on: fast transpiler in the inner loop (Vite/esbuild dev server, tsx for scripts) + tsc --noEmit as the gate in CI and the editor. Type errors then never block a dev-server reload but always block a merge. Corollary worth saying out loud: if CI doesn't run the checker, your team is not using TypeScript — it's using JavaScript with decorative annotations, because the strippers enforce nothing.
5. The expert lens
Erasure was the winning trade. By emitting no runtime machinery, TypeScript stayed a zero-cost, perfectly-interoperable layer — adoptable file-by-file, removable in principle, imposing nothing on runtimes or bundlers. The price is the hard boundary at runtime (validated data or faith — 3.7.7). Competing designs that added runtime types lost precisely because they demanded commitment before proving value. Design lesson: a layer that composes with the existing world beats a platform that replaces it, even when the platform is technically stronger.
tsconfig is a governance document. It encodes the team's actual safety contract: what the compiler is allowed to assume and what developers are forced to prove. Treat changes to it like API changes — reviewed, ratcheted, never quietly loosened. When auditing an unfamiliar repo, read tsconfig.json first; "strict": false plus scattered any predicts the bug tracker's contents.
Checking and running decoupled is why the DX works. The editor's red squiggles, the instant Vite reload, and the CI gate are three consumers of the same checker at different latencies — language server (milliseconds), transpiler (none — it doesn't check), CI tsc --noEmit (minutes). Understanding which tool is speaking explains every "it ran fine but CI failed" mystery: the runner never checks; only the checker checks.
Next: the vocabulary the checker speaks — 3.7.2: every core type, interface vs type, enums honestly, and the any/unknown/never lattice.
Recall
- Type erasure:
tscchecks, then deletes annotations — zero runtime cost, zero runtime protection; annotations on external data are claims, not checks. Gradual typing made adoption possible. (Exceptions that emit code:enum, parameter properties.) tsconfig.jsongroups into checking flags (the value), emit (target/module), resolution (moduleResolution,paths), plumbing (outDir,declaration,sourceMap).- strict is the master switch; strictNullChecks (absence must be typed and narrowed — kills the undefined-property bug class) and noImplicitAny (no silent
any) carry most of the value. Harden further withnoUncheckedIndexedAccess. Migrate by ratchet: per-flag, per-directory, never loosen. - Toolchain split: fast strippers run (esbuild/swc/tsx/Node's stripping — they never check),
tsc --noEmitgates (editor + CI). No checker in CI = decorative types.
Self-test: What exactly does tsc emit and what does that imply at runtime boundaries? Which two strict flags carry most of the value and what does each forbid? Why can code "run fine" locally yet fail CI type-check? Name two TS features that are not erasable.
Quiz Bank
FoundationalWhat does the TypeScript compiler actually produce, and what are the consequences?
tsc type-checks (lex → parse → semantic analysis, a classic front end per 3.1) and then emits plain JavaScript with all type annotations deleted — type erasure. Consequences: (1) zero runtime cost — no checks are inserted, nothing slows down; (2) zero runtime protection — types don't exist while running, so external data (APIs, forms, JSON.parse) is unverified and const u: User = await res.json() is faith, not verification; (3) gradual adoption — every valid .js is valid .ts, enabling file-by-file migration. Also: checking and emitting are independent (tsc emits despite errors unless noEmitOnError), and a few features do emit code (enum objects, parameter-property assignments).
FoundationalWhat do strictNullChecks and noImplicitAny each do, and why do they matter most?
strictNullChecks: without it, null/undefined are assignable to every type, so the checker is blind to JavaScript's most common runtime error — reading a property of undefined. With it, absence must be in the type (User | null) and narrowed before use, converting that entire bug class into compile errors. noImplicitAny: without it, anything the compiler can't infer silently becomes any — checking switched off, contagiously — so large swaths of "typed" code are actually unchecked. With it, ambiguity is an error you must resolve. Together they are the difference between TypeScript as a proof system and TypeScript as decoration; a migration that enables nothing else but these two captures most of the product's value.
AppliedExplain the modern TS toolchain: who strips, who checks, and why the split exists.
Two jobs, deliberately separated. Strippers (esbuild, swc, tsx, Babel's TS preset, Node's built-in type stripping) delete annotations and run/bundle the result in milliseconds — they perform no checking whatsoever, which is exactly why they're fast. The checker (tsc --noEmit, and the same engine behind the editor's language server) does full semantic analysis but is comparatively slow. The split gives each consumer the right latency: editor feedback in milliseconds (language server), dev-server reloads with zero type overhead (stripper), and a rigorous merge gate (checker in CI). It also explains the classic mystery "it ran fine locally but CI failed the build": the runner never checks — only the checker checks. Rule: the inner loop may skip checking; CI must not.
InterviewWhich tsconfig options would you check first in an unfamiliar repo, and what does each tell you?
Read it as a governance document, checking-flags first: strict (and any per-flag disables under it) — the real safety level; strict: false predicts pervasive unchecked nulls and implicit any. noImplicitAny / strictNullChecks individually if strict is off — the two that matter. noUncheckedIndexedAccess — whether indexing is honest about missing elements. Then the wiring: target (what syntax ships — too old means helper bloat), module + moduleResolution (ESM vs CJS posture, 3.6.5 — node16 vs bundler mismatches cause the classic import-resolution bugs), paths (aliases the bundler must mirror), declaration (is this meant to be consumed as a library — 3.7.6), skipLibCheck (pragmatic speed vs re-checking dependencies). Finish by confirming CI actually runs tsc --noEmit — without that, none of the above is enforced.
StaffYour team inherits a 300k-line JS codebase and wants TypeScript. Design the migration so value arrives early and safety never regresses.
Phase 0 — infrastructure: add the toolchain without changing code: allowJs: true + checkJs: false, tsc --noEmit wired into CI (initially trivially green), fast stripper already in the build (esbuild/swc), skipLibCheck: true. Phase 1 — perimeter types: install @types/* for dependencies; write .d.ts for internal untyped core modules so new typed code gets real signatures at the borders. Phase 2 — new code strict: all new files are .ts under the full strict family — enforced by lint on file type, so the debt stops growing (the ratchet's pawl). Phase 3 — conversion by value, not by order: convert highest-leverage modules first — shared utilities, data models, the API boundary (where schema validation lands, 3.7.7) — because their types flow into every consumer. Use per-directory configs/project references so converted zones run strict while legacy zones stay allowJs. Phase 4 — flag ratchet: in legacy zones enable noImplicitAny first, then strictNullChecks (the painful, valuable one), directory by directory; track remaining anys/@ts-expect-errors as counted debt with a burn-down. Non-negotiables: never loosen a flag once green; every suppression carries a comment and a ticket; CI perf guarded (incremental builds, project references). Expected shape of value: refactoring confidence and boundary bugs drop within weeks (perimeter + models), long tail of null-safety lands with Phase 4. The anti-pattern to veto explicitly: a big-bang strict: true + thousand-any sweep — it pays full ceremony for near-zero checking and teaches the team that types lie.
Flashcards
FlashType erasure
tsc checks, then deletes types → zero runtime cost AND zero runtime protection. Boundaries need runtime validation.
FlashThe two flags that matter
strictNullChecks (absence in the type, narrowed before use) + noImplicitAny (no silent any). Most of TypeScript's value.
FlashNon-erasable features
enum (emits object), class parameter properties (emit assignments), old-target helpers. Prefer the erasable subset.
FlashStripper vs checker
esbuild/swc/tsx/Node strip without checking (fast). tsc --noEmit checks without running (the CI/editor gate). Both, always.
FlashMigration ratchet
New code strict → perimeter .d.ts → convert high-leverage modules → enable flags per directory → never loosen. Suppressions = counted debt.
Scenario Drill
DrillA team complains: 'TypeScript is useless — we still get undefined errors in production, and yesterday a PR with type errors deployed successfully.' Their tsconfig has strict: false, and deploys run vite build only. Diagnose both complaints precisely and fix the pipeline.
Both complaints are configuration, not TypeScript. Undefined errors in production: with strict: false, strictNullChecks is off — null/undefined are assignable to every type, so the checker literally cannot flag the exact bug class they're complaining about; simultaneously noImplicitAny being off means un-inferable code silently became any, switching checking off along whole call paths. Their types are decorative in precisely the places that hurt.
Type-errored PR deploying: vite build uses esbuild — a stripper, which by design performs no type checking — and nothing else in the pipeline runs the checker; the red squiggles in the editor were the only gate, and squiggles don't block merges. Fix the pipeline: (1) add "typecheck": "tsc --noEmit" and make CI run it as a required check — from this moment type errors block merges; (2) turn on noImplicitAny immediately (usually a modest error count — fix or explicitly annotate), then ratchet strictNullChecks on per directory (this one surfaces the real null-handling debt — burn it down where the production errors actually occur first, likely the API-response and config paths); (3) at the same boundaries add runtime validation (Zod schemas, 3.7.7) because erasure means even perfect types can't check network data; (4) add noUncheckedIndexedAccess for honest array/record access; (5) institute the ratchet rules — no flag loosened, suppressions ticketed. Then re-frame for the team: TypeScript wasn't failing; it was switched off in the two places that mattered — the flags that check, and the CI step that enforces.