BundlingGENERALFRAMEWORK-SPECIFICSPEC-EVOLVING

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 question

The response is typed as Order. Why is order.total undefined at runtime?

The user intent

A team wants fewer defects and better tooling, and wants the compiler to catch mistakes before users do.

The obvious build

Annotate the fetch result as Order and the compiler will make sure it is one.

Why it breaks

The annotation is an assertion about what you believe, not a check. Nothing inspects the response — the compiler simply believes you and moves on.

How it breaks in a real browser
  • 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).
  • any and 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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.

Parse, check, emit — and what each step guarantees
  1. 1
    Parse

    Reads TypeScript syntax into a tree.

    fails by Nothing much — a syntax error here is caught immediately.

  2. 2
    Type check

    Verifies your assertions are internally consistent.

    fails by Being skipped entirely by fast transformers, so nothing checks anything (Bundlers Compared).

  3. 3
    Emit

    Strips annotations, producing plain JavaScript.

    fails by Nothing — but this is where every type-level guarantee stops existing.

  4. 4
    Run

    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.

Assertion versus check
1// an assertion. Nothing verifies this.
2const order = await res.json() as Order
3order.total.toFixed(2) // TypeError if the server disagreed
4
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 drift
14
15const parsed = Order.safeParse(await res.json())
16if (!parsed.success) {
17 reportContractMismatch(parsed.error) // the earliest possible warning
18 return showError()
19}
20parsed.data.total.toFixed(2) // now genuinely a number

z.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.

Modelling a request
Independent flags
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 happen
A discriminated union
type 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 exists

The 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).

BoundaryTyped asActually isWhat to do
fetch responseWhatever you assertedWhatever the server sent todayParse and validate; derive the type from the schema
localStorage valueOften asserted after JSON.parseWhatever a previous release wroteValidate, and version the stored shape (Persistent Client State)
URL and query paramsFrequently stringAnything a user can typeParse into the expected shape; handle failure (URL Parameters)
postMessage dataAsserted at the receiverAnything the sender choseValidate — the sender may not be who you think (Talking to a Worker)
Third-party SDKIts published typesIts actual runtime behaviourTreat returned values as unvalidated input (Third-Party Scripts and the Supply Chain)
Your own modulesChecked by the compilerWhat the compiler verifiedTrust 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 any and 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 an aria-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

Failure modes
  • 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 undefined the compiler warned about and someone asserted away.
What can arrive out of order
  • 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).
Security
  • 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 string can 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).
Misreads
  • "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

How you would see this
  • Type-check time in CI, and whether the gate exists at all — many pipelines assume the bundler covers it.
  • Count of any, as assertions and @ts-expect-error over 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).
Slow device, slow network, large data, old tab
  • 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 any at the edge undoes a lot of careful modelling downstream.
What this costs
  • 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.

Securityxss
Domains that do not exist yet
  • 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.