The TypeScript Pipeline
TypeScript parses, type-checks and emits — and the type information is generally erased from the emitted JavaScript. Checking and emitting are separate concerns, which is why tools that skip checking entirely can still produce correct output.
If TypeScript has types, why does none of my type-checking happen at runtime?
Three representations and a discarded fourth. Source text becomes an AST; the AST is annotated by the checker with a type for every expression and a symbol for every name, producing the richest representation in this pipeline; then the emitter walks that same tree and writes JavaScript, dropping the annotations. What runs is the fourth representation — JavaScript source, on its way into an engine that will start the whole [[javascript-pipeline]] over again — and the question it can answer is "what does this do", never "what was this declared to be".
The emitter may erase a construct only if erasing it leaves the runtime behavior of the program unchanged — which holds for anything that exists purely in the type domain: type aliases, interfaces, generic type parameters, as assertions, parameter and return annotations. A construct that carries runtime meaning may not be erased, which is exactly why enum, parameter properties and decorator metadata still emit code. The checker's conclusions may inform emit only through this rule; TypeScript deliberately does not let a type change what the generated JavaScript computes.
Key points
- TypeScript's static type information is generally erased from the emitted JavaScript: no annotations, no interfaces, no generic parameters survive.
- There are therefore no runtime type checks. Data entering the program is unvalidated unless you validate it yourself.
any, assertions and type predicates are deliberate holes: the checker believes claims it cannot verify.- Type checking and emitting are separate jobs, which is why esbuild, swc and Node can emit TypeScript without ever building a type.
- The exceptions —
enum, parameter properties, namespaces,emitDecoratorMetadata— are exactly the constructs that carry runtime meaning, and they are whatisolatedModulesand--erasableSyntaxOnlyrestrict. - The output is JavaScript, so it then enters a JavaScript engine's pipeline and is compiled all over again — with no benefit from the types.
The sentence the whole language rests on
tsc 5.x. The set of constructs that emit code has shrunk over time — const enum inlining changed with isolatedModules, and --erasableSyntaxOnly (5.8) exists precisely to forbid the non-erasable constructs so that Node's and Deno's type-stripping loaders can run the file directly. Older codebases written before these flags contain constructs that a stripping loader cannot handle.TypeScript's static type information is generally erased from the emitted JavaScript. Almost everything that surprises people about the language follows from that one sentence, and everything that makes it practical follows from it too.
It means there are no runtime type checks. A function declared to take a User receives whatever the caller passed; if that value came from a network response or an any, nothing checks it, and the failure appears later, somewhere else, as an undefined property. It means typeof and instanceof still work exactly as they do in JavaScript, because they are JavaScript. And it means the compiler is not a runtime dependency: the artifact you ship has no trace of TypeScript in it.
It also decides the language's relationship to its ecosystem. Because nothing is added, TypeScript can describe libraries it does not control, run on any JavaScript engine, and be removed from a project incrementally. A design that reified types would buy runtime checks and give up all three — see [[type-erasure]] for the general trade and [[monomorphization]] for the opposite answer.
.ts file through tsc, and where the types stopimplementation- Source textyou write itA
.tsor.tsxfile. - ASTbuild timeA syntax tree that keeps full-fidelity positions and, unusually, trivia.Structure, including type-annotation nodes that have no JavaScript counterpart.
- Binder / symbol tablebuild timeThe tree plus symbols: declarations merged across files and declaration merging applied.What each name refers to, across a whole program rather than one file — see
[[name-resolution]]. - Checked programbuild timeThe tree with a type computed for every expression and every declaration.Every diagnostic the language exists to produce. This is the richest the program will ever be.
- Emitted JavaScriptbuild timeOrdinary JavaScript source at the configured target level.Downlevelling: newer syntax rewritten for older targets, module format converted.The types. Annotations, interfaces, type aliases, generic parameters and assertions are gone and cannot be recovered from the output.
- Declaration files (`.d.ts`)build timeTypes without implementations, for consumers.The only durable record of the type information — deliberately a separate artifact from the code.
- Executionrun timeWhatever the JavaScript engine makes of the emitted file.Actual values, with no relationship to the declared types beyond the programmer's discipline.
Read it asTwo artifacts come out of one input, and they diverge permanently: the .js carries behavior with no types, the .d.ts carries types with no behavior. Every runtime type-checking library exists to rebuild, by hand, a third artifact that the compiler deliberately does not produce.
Checking and emitting are separate concerns
This is the structural fact that explains the tooling landscape. Type checking asks whether the program is well-typed; emitting asks what JavaScript corresponds to this syntax. Because erasure is almost entirely syntactic — delete the annotations, downlevel the syntax — the second job does not need the first.
So esbuild, swc, Babel's TypeScript plugin and Node's built-in type stripping all emit correct JavaScript without ever building a type. They are enormously faster than tsc as a result, and they will happily emit a file full of type errors. The standard modern setup uses one tool for each job: a fast transpiler in the build, tsc --noEmit in CI and in the editor.
The catch is the small set of constructs that are *not* purely syntactic. A transpiler working one file at a time cannot know whether an imported name is a type or a value, or what the members of a const enum in another file are. That is what isolatedModules enforces: it rejects the constructs whose correct emit would require cross-file type information, so that per-file transpilation is sound.
| Tool | Type-checks? | Emits? | What it cannot do |
|---|---|---|---|
tsc | Yes | Yes | Be fast on a large program; checking is the expensive part and it is whole-program. |
tsc --noEmit | Yes | No | Produce anything runnable — this is the CI gate, not the build. |
| esbuild / swc | No | Yes | Handle constructs needing cross-file type information; requires isolatedModules-clean code. |
| Babel + TS plugin | No | Yes | The same, plus it has never had a type checker to fall back on. |
Node --experimental-strip-types | No | Strips in memory | Anything non-erasable — hence --erasableSyntaxOnly in tsc to keep a codebase compatible. |
Where the holes are
tsc 5.x targeting a modern ES version with no downlevelling. Targeting an older --target inserts helper functions and rewrites classes, generators and async into state machines, so the correspondence between input and output lines stops being one-to-one — which is what [[source-maps]] exists to repair.Erasure means the type system's guarantees stop at the boundary of the type-checked program. Three constructs punch holes in it deliberately, and all three are used constantly.
any disables checking for a value and everything reachable from it, silently. A type assertion — value as User — tells the checker to believe a claim it cannot verify; at runtime it is not there at all, so an assertion that is wrong produces no error at the assertion and a confusing one much later. And a type predicate — function isUser(x): x is User — is trusted on its declaration, not verified against its body, so a predicate whose implementation is wrong makes the checker confidently wrong everywhere it is used.
The boundary this matters most at is data entering the program: JSON.parse returns any, fetch().json() returns Promise<any>, and environment variables are strings that a developer asserts into shapes. A static type system proves the properties it models about the code it can see; it models nothing about bytes arriving over a socket. The fix is not more assertions but a validating parse at the boundary — a function that checks the shape at runtime and returns a typed value, so that exactly one place in the program is responsible for the claim.
1// input.ts2interface User { id: number; name: string }3 4function greet(u: User): string {5 return "hi " + u.name6}7 8const raw = JSON.parse(input) as User // no check happens here, ever9console.log(greet(raw))10 11// output.js — what actually runs12function greet(u) {13 return "hi " + u.name14}15const raw = JSON.parse(input)16console.log(greet(raw))The interface is gone. The assertion is gone. greet accepts anything, and if raw is null the failure is a TypeError inside greet about a property of null — three frames from the assertion that was actually wrong.
The constructs that are not erased
The word in the load-bearing sentence is *generally*. A handful of TypeScript constructs have runtime meaning and therefore emit code, and knowing which ones they are is the difference between a codebase that can be transpiled by any tool and one that cannot.
A non-const enum emits a real object, because enum members are values you can index in both directions at runtime. Parameter properties — constructor(private x: number) — emit an assignment. Namespaces with runtime members emit an object and an IIFE. And under experimentalDecorators with emitDecoratorMetadata, the compiler emits design:type metadata derived from the type annotations, which is the one place where type information genuinely survives into the output — and the reason dependency-injection frameworks in this ecosystem work at all.
That last one is worth dwelling on because it is the exception that proves the rule: it required an explicit opt-in flag, a runtime library (reflect-metadata), and a decorator proposal that has since been replaced by a standard one that does *not* emit type metadata. Reifying types is possible; it is simply not free, and TypeScript charges for it explicitly rather than by default.
| Construct | Emits code? | Consequence |
|---|---|---|
interface, type, generic parameters | No | Zero runtime cost; zero runtime protection. |
as assertions, satisfies, non-null ! | No | A wrong assertion fails somewhere else, later, with an unrelated message. |
enum (non-const) | Yes — an object with forward and reverse mappings | Real code in the bundle; a per-file transpiler can handle it, unlike const enum. |
const enum | Inlined by tsc; rejected under isolatedModules | Needs cross-file type information, so per-file transpilers cannot emit it correctly. |
| Parameter properties | Yes — an assignment in the constructor | Non-erasable syntax; blocked by --erasableSyntaxOnly. |
experimentalDecorators + emitDecoratorMetadata | Yes — design:type metadata from the annotations | The one route by which declared types reach runtime; opt-in, legacy, and replaced by a standard decorators proposal that emits no such metadata. |
How it works
The steps, in the order the compiler takes them.
- The parser builds a full-fidelity AST including nodes for type annotations, which have no JavaScript equivalent.
- The binder walks declarations and builds symbols, merging declarations across files so that an interface declared twice becomes one symbol.
- The checker computes a type for every expression lazily, on demand, caching aggressively — a design that makes editor queries fast and whole-program checking expensive.
- The emitter walks the same tree and writes JavaScript: type-only nodes are skipped, syntax newer than
--targetis downlevelled, and module syntax is converted to the configured format. - A declaration emitter writes
.d.tsfiles containing the types with the bodies removed, which is what consumers of a published package type-check against. - Source maps are emitted alongside, mapping output positions back to input positions, because after downlevelling the correspondence is no longer obvious — see
[[source-maps]].
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- An API response shape changes, every file still type-checks because the response was asserted into a type at the boundary, and the failure surfaces as
Cannot read properties of undefineddeep in a rendering function. - A type predicate's implementation is wrong — it checks one field and claims a whole type — so the checker propagates a false fact through the codebase and every downstream error message is misleading.
- A build using esbuild succeeds and CI fails, or worse, CI is not running
tsc --noEmitat all and type errors ship: the transpiler never looked at a type. - A
const enumin a shared package breaks a consumer that transpiles per file, with an error about an undefined identifier at runtime rather than at build time. - Upgrading
tscproduces new errors in code that did not change, because inference improved; the code was always wrong and nothing had proved it yet. - A generic function is written expecting the type parameter to be available at runtime —
new T()— and there is nothing to instantiate, becauseTdoes not exist after emit.
When it helps
- Adopting types incrementally in an existing JavaScript codebase, precisely because nothing about the runtime changes.
- Describing libraries you do not control: a
.d.tscan type a package written in plain JavaScript without touching it. - Keeping the shipped artifact free of any compiler runtime, which matters when every kilobyte is downloaded.
- Splitting the build for speed — a fast transpiler for the dev loop, a full check in CI — which is only possible because the two jobs are separable.
When it hurts
- At every trust boundary. Network responses,
localStorage, environment variables andpostMessagepayloads all arrive untyped, and the type system will confidently describe whatever you assert about them. - When a design needs runtime type information — dispatch on a generic parameter, automatic serialisation, dependency injection by type — it must be rebuilt by hand or by a decorator-metadata mechanism that is now legacy.
- On very large programs, where whole-program checking becomes the slowest part of the build and incremental modes must be configured carefully — see
[[incremental-compilation]].
What it costs
Every one of these is paid by something.
- Erasure buys zero runtime cost, universal engine compatibility and incremental adoption, and pays with the complete absence of runtime enforcement: every guarantee stops at the edge of the checked program.
- Separating checking from emit buys a fast build path and a whole tooling ecosystem, and pays with a configuration in which it is entirely possible to ship code that never passed a type check — a failure mode that did not exist when one tool did both.
- A structural, deliberately unsound type system buys the ability to describe JavaScript as it is actually written, and pays with holes —
any, assertions, unchecked predicates, bivariant method parameters — that a sound system would not have. See[[type-soundness]]. - Full-fidelity trees and lazy, cached checking buy excellent editor responsiveness and pay in memory and in whole-program check time that grows faster than the codebase.
What else you could do
What a different compiler or language does instead, and when that is better.
- Reified generics, as in C# or Java's arrays, keep type information at runtime so it can be tested and reflected on — at the cost of a runtime representation and a VM that must carry it. See
[[type-erasure]]. - Monomorphization, as Rust and C++ do, specialises generic code per type at compile time, so the type is gone but the specialised code remains. Different erasure, different bill — see
[[monomorphization]]. - Runtime schema validation (Zod, io-ts, Valibot) rebuilds the check at the boundary and derives the static type from it, so one declaration produces both. This is the standard answer to the hole, and it costs bundle size and a validation pass.
- A gradually typed language with runtime contracts, such as Typed Racket, inserts checks at the typed/untyped boundary so an error is reported where the wrong value crossed rather than where it was used — see
[[gradual-typing]].
See it for yourself
The flag, dump or tool that shows you this directly.
tsc --noEmitin CI is the check;tsc --emitDeclarationOnlyshows what consumers will see. Runtsc file.ts --target esnextand read the.jsnext to the.ts— the diff *is* erasure.npx tsc --showConfigprints the fully resolved configuration, which is where most "why did it emit that" questions actually end.tsc --generateTrace traceDirplus--diagnosticsattributes checking time; a pathological conditional type shows up there and nowhere else.- The TypeScript AST Viewer (ts-ast-viewer.com) shows the tree, the symbols and the computed types for a snippet — the closest thing to
-ast-dumpthis language has. - For what a stripping loader accepts,
tsc --erasableSyntaxOnlyreports exactly the constructs that would break it.
Plausible wrong readings
Stated the way a confident engineer states them.
- "TypeScript checks types at runtime." It checks them at compile time and then deletes them. Nothing in the emitted file knows what a
Useris. - "If it compiles, the data is the right shape." It means the code is consistent with the claims you made. Every claim about external data was made by you, unverified.
- "esbuild is a faster TypeScript compiler." It is a faster TypeScript *emitter*. It does not type-check, which is most of what
tscspends its time on. - "Adding types will make it faster." The types are erased before any engine sees them; the JavaScript engine specialises on observed runtime types instead — see
[[javascript-pipeline]]. - "
asconverts the value." It converts the checker's opinion. The value is untouched, and if the opinion is wrong nothing says so at that line.
Misconceptions
The claim, and what is actually true.
[[monomorphization]].tsc emits by default even when the program has type errors, unless noEmitOnError is set. A build can be red and still produce output.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
TypeScript adds types to JavaScript, checks them, and then deletes them. What runs is JavaScript with the annotations stripped out, which is why nothing checks types while your program is running, and why you have to validate data that comes from outside the program yourself.
practical
Set up two jobs, not one: a fast transpiler (esbuild, swc, or your bundler) for the build, and tsc --noEmit in CI and the editor for the check. Keep the code isolatedModules-clean so the two agree. Then put a real runtime validator at every boundary where data enters — HTTP responses, storage, environment, message handlers — and let the static types flow from the validator rather than from an assertion. Assertions and any should be rare enough to grep for.
advanced
The interesting question is what erasure costs the checker's own design. Because no type information exists at runtime, the type system is free to be far more expressive than one that must be represented in memory — conditional types, mapped types, template literal types, variadic tuples are all possible precisely because nothing has to be materialised. The bill arrives as check time, since these constructs are evaluated by the checker rather than by a runtime, and as unsoundness: the system is deliberately unsound in several places (bivariant method parameters, unchecked assertions and predicates) because soundness would reject too much real JavaScript. Both are consequences of the same decision — see [[type-soundness]] and [[gradual-typing]].
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
tsc 5.x. Which constructs are erasable has moved: const enum behaviour changed with isolatedModules, the legacy experimentalDecorators metadata emit is separate from the standard decorators that shipped later, and --erasableSyntaxOnly arrived in 5.8. A codebase from 2018 contains constructs that today's stripping loaders reject.If you were asked this in an interview
- What happens to a TypeScript interface when the code is compiled?
- Why can esbuild emit TypeScript ten times faster than
tsc, and what do you lose? - Where does a TypeScript codebase actually need runtime validation, and why does the type system not provide it?
Connections
- Testing & Reliability Engineering — Contract testing and schema validation at service boundariesErasure moves the entire burden of checking external data from the compiler to the boundary, so the guarantees a TypeScript codebase actually has at runtime are exactly the ones its validators and contract tests establish. That discipline is owned there, and this lesson is the reason it is not optional.