TypeScript in the Build
Parse, type check, emit. The types are erased before anything runs, so nothing they promised is enforced at the network boundary.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
The response is typed as Order. Why is order.total undefined at runtime?
A team wants fewer defects and better tooling, and wants the compiler to catch mistakes before users do.
Annotate the fetch result as Order and the compiler will make sure it is one.
The annotation is an assertion about what you believe, not a check. Nothing inspects the response — the compiler simply believes you and moves on.
- The annotation is an assertion about what you believe, not a check. Nothing inspects the response — the compiler simply believes you and moves on.
- Types are erased at build time. At runtime there is no
Order, no field list, and no check of any kind (The Module Graph). - The server changed the shape and nothing failed at the boundary, so the error surfaces somewhere unrelated and much later (Long-Lived Clients and Version Skew).
anyand unchecked assertions spread silently: one at the boundary disables checking through everything downstream that touches it.- Most builds strip types without checking them at all, so a type error can ship — the build succeeded because it never looked.
What is actually happening
In the browser, not in the framework.
- The pipeline is parse → type check → transform/emit → JavaScript, and the crucial detail is that the middle step is optional and increasingly separate from the others.
- Fast transformers strip type annotations without any type analysis. They are quick precisely because they do not build the program graph a checker needs, so your bundler is usually not type-checking your code.
- That makes type checking a separate obligation — an editor, a CI step, an explicit command — and a build that passes proves only that the syntax was strippable (Bundlers Compared).
- Erasure means every type-level guarantee stops at the boundary of your own code. Data arriving from the network, from storage or from a third-party script is untyped in reality and typed only in your belief about it.
- Runtime validation is what re-establishes the guarantee: parse the response, check it against a schema, and derive the static type from that schema so the two cannot drift apart.
What this makes the browser do
And which of it is avoidable.
- None from the types themselves — they are gone. TypeScript adds zero runtime weight by construction.
- Runtime validation is real work: parsing and checking a large response costs main-thread time proportional to its size, which matters for big payloads on slow devices (The Real Cost of JavaScript).
- Some TypeScript features do emit code — enums and decorators produce runtime constructs, unlike type annotations — which occasionally surprises people auditing bundle contents (Tree Shaking).
The gap between what you wrote and what runs
Everything about typed frontend code follows from one fact: the types are gone before the code executes. Inside your program that is fine — the checker verified the parts it could see. At the edges it is not, because the checker never saw the server.
So an annotation on a fetch result is a promise you made to yourself. It produces excellent autocomplete for fields that may not exist, and it is the most common way a typed codebase acquires a confident, wrong belief.
- 1Parse
Reads TypeScript syntax into a tree.
fails by Nothing much — a syntax error here is caught immediately.
- 2Type check
Verifies your assertions are internally consistent.
fails by Being skipped entirely by fast transformers, so nothing checks anything (Bundlers Compared).
- 3Emit
Strips annotations, producing plain JavaScript.
fails by Nothing — but this is where every type-level guarantee stops existing.
- 4Run
Executes untyped JavaScript against real data.
fails by Data that does not match, with no check anywhere to notice.
Steps two and four are the lesson: the check is optional, and by the time real data arrives there is nothing left to check it against.
1// an assertion. Nothing verifies this.2const order = await res.json() as Order3order.total.toFixed(2) // TypeError if the server disagreed4 5// a check. The type is now earned.6import { z } from 'zod'7 8const Order = z.object({9 id: z.string(),10 total: z.number(),11 lines: z.array(z.object({ sku: z.string(), amount: z.number() })),12})13type Order = z.infer<typeof Order> // derived, cannot drift14 15const parsed = Order.safeParse(await res.json())16if (!parsed.success) {17 reportContractMismatch(parsed.error) // the earliest possible warning18 return showError()19}20parsed.data.total.toFixed(2) // now genuinely a numberz.infer is the important line: one definition, used at runtime and at compile time, so the schema and the type cannot disagree.
Where to spend the effort
Given erasure, the highest-value work is concentrated at the edges. Inside your own program the compiler is genuinely doing its job; at the boundary it is trusting you, and that is where the defects come from.
interface State {
loading: boolean
error?: Error
data?: Order
}
// loading && error && data — representable, meaningless
// data && !loading with a stale error — representable
// the component must guard combinations that cannot happentype State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: Order }
// exhaustive switch; impossible states cannot be written;
// and narrowing means data is defined exactly where it existsThe second version makes the illegal combinations unrepresentable rather than merely undesirable, so the compiler enforces what would otherwise be a convention — and the exhaustive switch means adding a fifth state surfaces every place that needs updating instead of silently falling through (Loading, Error, Empty — The States You Did Not Render).
| Boundary | Typed as | Actually is | What to do |
|---|---|---|---|
fetch response | Whatever you asserted | Whatever the server sent today | Parse and validate; derive the type from the schema |
localStorage value | Often asserted after JSON.parse | Whatever a previous release wrote | Validate, and version the stored shape (Persistent Client State) |
| URL and query params | Frequently string | Anything a user can type | Parse into the expected shape; handle failure (URL Parameters) |
postMessage data | Asserted at the receiver | Anything the sender chose | Validate — the sender may not be who you think (Talking to a Worker) |
| Third-party SDK | Its published types | Its actual runtime behaviour | Treat returned values as unvalidated input (Third-Party Scripts and the Supply Chain) |
| Your own modules | Checked by the compiler | What the compiler verified | Trust it — this is where types genuinely pay |
How to build it
Most important first.
- Validate at the boundary, trust inside. Every value entering from outside your program — network, storage, URL, message, third party — is parsed and checked once, and typed thereafter (The Life of a Fetch).
- Derive the static type from the runtime schema rather than declaring both. Two hand-written definitions of the same shape will diverge, and the compiler cannot tell you when.
- Run a real type check in CI as its own gate, since the bundler is not doing it.
- Treat
anyand non-null assertions as debts with locations, not as tools. They are occasionally correct and always worth a comment saying why. - Prefer types that make illegal states unrepresentable — a discriminated union for a request's states beats four independent booleans, and removes the impossible combinations rather than documenting them (Loading, Error, Empty — The States You Did Not Render).
- Type the API contract from a shared source where one exists, so a server change surfaces as a compile error rather than as a runtime surprise (How API Shape Drives UI Complexity).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Types can make an accessibility contract enforceable at compile time. A component that requires an accessible name can express that as a union — either visible children, or an
aria-label, or anaria-labelledby— so a call site with none of them fails to compile (What a Component Owes Its Caller). - That is one of the few places where an accessibility requirement can be checked mechanically rather than reviewed, which matters because review is exactly where these are missed.
- Types cannot check whether a name is *useful*, whether focus lands somewhere sensible, or whether a change is announced. They move a subset of the problem to build time; the rest still needs a keyboard and a screen reader (Accessibility Testing).
What can go wrong
- An assertion at the boundary that is simply wrong, producing confident autocomplete for fields that do not exist.
- A build that succeeds with type errors because nothing type-checked, discovered only when someone opens an editor.
- Validation applied to the happy path only, so an error response is parsed as a success shape.
- Schema and type maintained separately and drifting, which is worse than no types because the wrong information looks authoritative.
- Over-modelling: types elaborate enough that changing a field becomes an afternoon, which trains people to reach for
any. - Assuming an optional field is present because it usually is — a runtime
undefinedthe compiler warned about and someone asserted away.
- A deploy can change the API shape while an old client is running, so the boundary must fail clearly rather than propagating an unexpected shape into logic that assumed otherwise (Long-Lived Clients and Version Skew).
- Types are not validation, and treating them as such is the security-relevant version of this lesson: unvalidated input flowing into a DOM sink is an injection risk whatever it is annotated as (Cross-Site Scripting).
- A value typed
stringcan be anything at runtime, including markup — so escaping and sanitization decisions cannot be made on the basis of a type (Sanitization and Trusted HTML). - Runtime validation at the boundary is a genuine security control as well as a correctness one: it rejects malformed input before it reaches logic that assumed a shape.
- Types erase, so nothing they express constrains what a client actually sends. Server-side validation remains the only enforcement (What the Frontend Is Responsible For in Auth).
- "TypeScript validates my API responses." It records what you believe. Nothing checks it (The Life of a Fetch).
- "If it compiles, it is correct." It means the syntax was valid and — if a checker actually ran — that your assertions are internally consistent. Neither implies the data matches.
- "The build type-checks." Usually it does not. Fast transformers strip types without analysing them.
- "Types make runtime validation unnecessary." Exactly backwards: erasure is why validation is necessary.
- "Types add bundle weight." Annotations add none. A few features emit runtime code; the annotations themselves vanish.
Measuring it, and what changes in the field
- Type-check time in CI, and whether the gate exists at all — many pipelines assume the bundler covers it.
- Count of
any,asassertions and@ts-expect-errorover time, as a directional signal about where the boundary is leaking. - Runtime validation failures in error tracking, which is the earliest possible warning that an API changed shape (Frontend Error Tracking).
- Where those failures cluster by release, which distinguishes a client bug from a server-side contract change (Release Health).
- On a large payload, boundary validation is a real main-thread cost and may deserve to be narrowed to the fields actually used, or moved off-thread (When a Worker Is Actually the Answer).
- With long-lived clients, an old build's expectations meet a newer API, and boundary validation is what turns a confusing crash into a clear, reportable failure (Long-Lived Clients and Version Skew).
- On a large team, the boundary discipline matters more than the type sophistication — one unvalidated
anyat the edge undoes a lot of careful modelling downstream.
- Runtime validation costs bytes and main-thread time, and is the only thing that makes the types true at the boundary.
- Splitting type checking from transpilation makes builds much faster and means the build no longer tells you the code is correct — the check has to be added back deliberately.
- Precise types catch more and cost more to change; the useful setting is strict at boundaries and pragmatic inside.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALType erasure is a design property of TypeScript itself, so the boundary problem exists in every framework, bundler and runtime that uses it — it is not a toolchain configuration issue.
- FRAMEWORK-SPECIFICWhether the build type-checks differs sharply by toolchain: some run the full compiler as part of the build, while fast transformer-based setups strip types without analysis and require a separate check step — so "the build passed" means different things in different projects.
- SPEC-EVOLVINGRuntimes are moving toward stripping type annotations natively, which changes where erasure happens but not that it happens; boundary validation remains necessary regardless of who removes the types.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Compilers & Programming Languages — type checking and emit are separable phases of a compiler, and the modern frontend build separates them for speed, which is precisely why "the build passed" stopped meaning "the types are sound".
- — Software Design — making illegal states unrepresentable is a design technique that happens to be checkable here, and its value survives whether or not the language enforces it.