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.

Pipeline: TypeScript source parsed to an AST, checked for type errors, then emitted as plain JavaScript; diagnostics branch off the checker
Figure 1 — The compiler's pipeline. Parse, check, emit. Errors are produced by the check stage; the JavaScript you run is produced by the emit stage. Nothing forces the two to happen together.

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.

Source files feed tsc which only checks, and tsx/esbuild/Vite which only strip types; runtimes and bundles execute plain JavaScript
Figure 2 — The production toolchain. The checker is the slow, thorough component that gates CI; the transpiler is the fast component that powers your editor and dev server. Neither substitutes for the other.

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.

Left: TypeScript with annotations, interface, and generic parameter. Right: emitted JavaScript with every type construct removed
Figure 3 — Erasure in one picture. Everything red on the left exists only for the checker. If it is not in the emitted JavaScript, the runtime cannot see it — and neither can an attacker, a debugger breakpoint, or 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:

ConstructExists at design timeExists at runtime
interface, type aliasesyesno
annotations (: string) and genericsyesno
enum (regular)yesyes — emits an object (transformed syntax)
namespace with runtime codeyesyes — emits an object
classes, functions, plain valuesyesyes

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. ES2022 keeps modern syntax as-is; a lower target makes the compiler rewrite newer syntax into older forms (for example, class into a function-plus-prototype when targeting ES5).
  • module — the module format of the emit: ESNext keeps import/export; CommonJS rewrites them to require/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.