The Compiler Pipeline
Intermediate · 19 min read · ▶ live playground · ✦ checkpoint
tsc is not one magic step. It is a pipeline: TypeScript reads your project, turns source text into structure, connects names across files, checks types, reports diagnostics, and only then emits JavaScript when emitting is enabled.
That mental model pays off whenever a command surprises you. If npx tsc --noEmit fails, it did not "fail to build JavaScript"; it found a problem before the project reached the output-writing part of the pipeline.
The project enters as files
The compiler starts with a program, the full set of files TypeScript is checking together. Your tsconfig.json from 9.2 chooses the starting files with include and configures the rules with compilerOptions. Module resolution from 9.1 pulls in imported files, and declaration files from 9.3 describe packages and legacy JavaScript.
In a small project, the input might look like this:
tsconfig.json
src/app.ts
src/money.ts
types/legacy-members.d.ts
node_modules/date-tools/dist/index.d.tsDo not picture tsc checking one file, forgetting it, then moving on. TypeScript is most useful when it can see the project graph: file A imports file B, file B exports a function, a .d.ts file says what a package returns, and your app calls that package. The checker needs the whole graph to decide whether the promises line up.
Scanner and parser: text becomes structure
The first two stages are about reading.
A scanner breaks raw source text into tokens: words, punctuation, strings, numbers, and keywords. It does not understand your app yet. It just turns characters into meaningful pieces.
A parser turns those tokens into a tree-shaped structure called an abstract syntax tree: "this is a function declaration," "this is a parameter," "this is a call expression," "this is a type annotation." If the text is not valid TypeScript syntax, the parser can report a diagnostic before type checking even begins.
Source text
const total: number = price + tax;
Scanner tokens
const | total | : | number | = | price | + | tax | ;
Parser structure
variable declaration named total
annotated as number
initialized by price + taxAt this point, TypeScript has structure but not meaning. It can see a name like price, but it has not yet connected that name to the place where price was declared.
Binder and checker: names become facts
The binder connects declarations to names. It builds the compiler's symbol table: this formatMoney import points at that exported function; this ReceiptLine type comes from that module; this readLegacyMember name came from a .d.ts declaration.
Then the checker turns those connected names into type facts. It answers the questions you care about:
- Does this argument fit the parameter type?
- Does this object have the required properties?
- Is this import asking for a name the module actually exports?
- Is a type-only name being used as a runtime value?
- Does this return statement satisfy the function's boundary promise?
When the answer is no, the checker produces a diagnostic: a file, line, column, error code, and message. You learned the shape in 1.5; now you know where it comes from.
Tool transcript: npx tsc --noEmit
src/order.ts:5:7 - error TS2322: Type 'number' is not assignable to type 'string'.
5 const receiptId: string = 42;
~~~~~~~~~
Found 1 error in src/order.ts:5That diagnostic is a checker-stage result. The compiler already parsed the file and bound the name. It failed when the checker compared the promised set of allowed values, string, with the actual value's type, number.
Emitter: types erase, JavaScript appears
The emitter writes output files. It removes type-only syntax, lowers syntax when target asks for older JavaScript, and shapes module output according to module. This is where 1.5's erasure becomes a pipeline stage: type annotations, type aliases, interfaces, import type, and .d.ts declarations are for the earlier stages, not for runtime.
In a project that uses TypeScript to emit, the command is shaped like:
npx tsc
# parse -> bind -> check -> emit JavaScript and declaration output if configuredIn a modern app with noEmit: true, the command is shaped like:
npx tsc --noEmit
# parse -> bind -> check -> stop; write no JavaScript filesThat second command can still fail because failure belongs to the earlier stages. noEmit only removes the final output-writing step.
Why bundlers can be fast and still need tsc
Many build tools and fast transpilers are optimized for turning files into runnable JavaScript quickly. They can strip TypeScript syntax, bundle modules, transform modern JavaScript, and serve your app fast during development.
The tradeoff is scope. A fast file transform can often process one file at a time. It may not build the full TypeScript program, bind every symbol across every file, read every relevant .d.ts, and run the project checker the way tsc --noEmit does.
That is why the modern project loop from 9.2 usually has two commands:
npx tsc --noEmit # project type check
npm run build # framework or bundler outputThe bundler proves "I can produce runnable output." tsc --noEmit proves "the TypeScript program is coherent." You want both signals because they answer different questions.
isolatedModules: write code a file-at-a-time tool can understand
isolatedModules is a compiler option that checks whether each file can be safely transformed without relying on whole-program TypeScript knowledge. It matters when a tool other than tsc is doing the emitting file by file.
This flag does not make TypeScript stricter about your domain types. It is a portability check for syntax. If a file uses a TypeScript feature that only makes sense when the compiler can see the whole program, isolatedModules asks you to rewrite it into a form a one-file transform can handle.
Read it as a teamwork setting: "write TypeScript that tsc, a bundler, and a fast transpiler can all understand in the same way." That fits the 9.2 baseline where TypeScript checks and a build tool emits.
Watch and incremental builds
At project scale, the pipeline runs many times. Watch mode keeps the compiler alive:
npx tsc --noEmit --watch
# initial check, then re-check after changed filesAn incremental build stores enough information from a previous run to avoid repeating work unnecessarily. The mental model is simple: the compiler remembers the project graph and reuses clean parts when a file changes. If you edit src/money.ts, TypeScript does not have to relearn unrelated files from scratch before telling you which dependents are affected.
You do not need to tune this on day one. You only need the shape: TypeScript is a project graph plus a pipeline. Watch and incremental modes make that graph stay warm while you work.
Section 9 gave you the toolchain map: modules connect files, tsconfig sets the rules, declaration files describe outside JavaScript, and the compiler pipeline turns all of that into diagnostics and output. The milestone project uses those pieces together in a small typed API client.
Checkpoint
Answer all three to mark this lesson complete