Inside the TypeScript Compiler
The setup lesson made the checker run; this lesson explains what it actually does. The mental model you build here — checking and emitting are two separate jobs — explains behaviors that otherwise look arbitrary: why fast tools skip checking, why some syntax survives to the runtime and some does not, and why --noEmit is the most important flag in real projects.
Two jobs: check and emit
Every tsc invocation does up to two things: it verifies that your program's types are consistent, and it writes JavaScript output. The insight that took TypeScript years to teach the ecosystem: these jobs are separable, and separating them is what modern toolchains do.
The two jobs are separable
Purpose of the example: show the three ways real projects split the work, and why each exists.
# Job 1 only — verify types, write nothing:
npx tsc --noEmit
# Job 2 only — transpile fast, check nothing (esbuild example):
npx esbuild src/index.ts --outfile=dist/index.js
# Both jobs — the classic single-step build:
npx tsc
What you should see: all three succeed on valid code, at very different speeds. esbuild (and tsx, Vite, SWC) is often 10–100× faster than tsc precisely because it skips the check entirely. That is the architecture in Figure 2: one tool checks, another runs.
What we learn: "TypeScript is slow" almost always means "type checking is slow" — and checking is optional at dev time as long as it is mandatory in CI. This split is why large TypeScript codebases keep instant dev loops.
Type erasure: the output is plain JavaScript
The emit stage does something that surprises developers coming from languages like Java or C#: it removes the type system rather than implementing it. This is called type erasure.
Purpose of the example: prove to yourself that no types exist at runtime — the single most important fact for debugging TypeScript.
// src/erasure.ts — the file you compile
interface UserId { // 1. interface: pure type syntax
value: number;
}
function toLabel(id: UserId): string { // 2. annotations: pure type syntax
return `user-${id.value}`;
}
const id: UserId = { value: 42 }; // 3. annotation on a variable
console.log(toLabel(id));
// dist/erasure.js — what tsc emits (target: ES2022)
function toLabel(id) { // annotation gone
return `user-${id.value}`;
}
const id = { value: 42 }; // annotation gone
console.log(toLabel(id));
// the interface never existed — it is not even a comment
What you should see: functionally identical JavaScript, minus every type construct. Nothing in the emitted file knows UserId ever existed. Contrast this with Java generics, which exist at runtime — in TypeScript, instanceof cannot test an interface, reflection cannot read a generic, and typeof reports JavaScript types only.
console.log.Design time versus runtime: the key distinction
Erasure splits the world in two, and classifying every construct into its side prevents whole categories of confusion:
| Construct | Exists at design time | Exists at runtime |
|---|---|---|
interface, type aliases | yes | no |
annotations (: string) and generics | yes | no |
enum (regular) | yes | yes — emits an object (transformed syntax) |
namespace with runtime code | yes | yes — emits an object |
| classes, functions, plain values | yes | yes |
What we learn: most of the language is pure type syntax that vanishes; a small set of TypeScript-specific constructs (enums, namespaces) is transformed into JavaScript instead of erased. That second category is exactly what Node's native type stripping cannot execute — connecting back to the setup lesson — and why modern style prefers unions of string literals over enums.
Control the emit, not just the check
Three tsconfig options govern what the emit stage writes. They matter because they change the shape of the JavaScript other tools consume.
target, module, and moduleResolution
target— how old the emitted JavaScript may be.ES2022keeps modern syntax as-is; a lower target makes the compiler rewrite newer syntax into older forms (for example,classinto a function-plus-prototype when targeting ES5).module— the module format of the emit:ESNextkeepsimport/export;CommonJSrewrites them torequire/exports.moduleResolution— how import specifiers are resolved to files."Bundler"matches what Vite/esbuild do and is the modern default for web projects; Node-native projects use"NodeNext".
Wrong choices here produce code that checks fine but fails at runtime with "Cannot use import statement outside a module" — a config bug, not a type bug.
noEmit and why check-only runs dominate
In a Vite/esbuild project, tsc frequently never emits at all: the bundler produces the JavaScript, and tsc --noEmit runs purely as a verifier. This is the standard architecture for React and Next.js projects in 2026 — and it explains a recurring team argument: if the build passes but tsc --noEmit fails, the project is not fine, because the fast build never looked at your types.
When checking and running disagree
Sometimes you know something the checker cannot prove — typically data from outside the program. TypeScript provides two suppression comments with different contracts; choosing deliberately is a professionalism marker.
Purpose of the example: distinguish the "trust me" switch from the "prove me" switch.
@ts-expect-error: the self-deleting suppression
// @ts-expect-error: asserts THIS LINE has a type error, and fails the build
// if the error ever disappears (e.g. after a dependency upgrade).
// Use for: errors you understand but cannot fix locally.
// @ts-expect-error JSON.parse returns any; the typed parse lands
// with the runtime-validation work in the next sprint
const config = loadUntypedConfig();
@ts-ignore, and why it is almost never the right choice
// ts-ignore: suppresses whatever errors appear on the next line, forever,
// even if they change. Almost never the right choice.
// @ts-ignore
const legacy = legacyGlobal.doAnything();
What you should see: with @ts-expect-error, fixing the underlying error turns the suppression itself into an error ("Unused '@ts-expect-error' directive") — the comment becomes self-deleting, so stale suppressions cannot accumulate. @ts-ignore gives no such guarantee.
What we learn: suppressions are technical debt with a due date. Prefer designs the checker can verify; use @ts-expect-error with an explanatory comment when you must; audit both with a lint rule in CI.
Practice: interrogate the compiler
Predict the emitted output
For the file below, write by hand what tsc emits with target: ES2022, then run the compiler to check:
type Role = "admin" | "viewer"; // 1
interface Account { role: Role } // 2
function describe(a: Account): string { return a.role; } // 3
console.log(describe({ role: "admin" }));
What you should see: all three type constructs erased; only the function body, the object literal, and console.log remain. If your prediction kept type or interface, revisit the erasure table above.
Trace a type error through the pipeline
Create src/bug.ts with const n: number = "42";, run npx tsc --noEmit, and read the diagnostic: file, line, column, error code, and message. Then run npx tsc and confirm no output file is written — by default the compiler refuses to emit when the check fails, because shipping code that failed verification defeats the point. (The escape hatch exists via noEmitOnError; leave it closed.) This trace is the reflex the rest of the track assumes.