← Back to the library
FoundationsHow it works · Core · 13 min

The TypeScript type system and where it stops

What does TypeScript actually guarantee, and why can a fully typed app still crash on bad data?

TypeScript is a compile-time checker for JavaScript: it compares shapes structurally, narrows types through control flow, and then erases every type before the code runs. Anything that enters at runtime (JSON, env vars, forms, messages) is unchecked until you validate it.

THE MENTAL MODEL

Treat TypeScript as a proof assistant that sits beside your JavaScript, not inside it. It proves claims about values whose origin it can see, using shapes rather than names (if it has the right properties, it fits), and it refines a union step by step as your if and switch statements rule cases out. Then every annotation, interface and generic is deleted, by tsc, a bundler or Node's own type stripping, and the plain JavaScript runs with no memory of them. So the guarantees are exactly as strong as the edges: a value typed by `as`, `any` or an unvalidated res.json() is a claim nobody checked. Good TypeScript keeps those edges few and explicit: parse untrusted input into precise types once at the boundary, model states as discriminated unions so impossible combinations cannot be written, and let the compiler do exhaustiveness checks instead of tests.

HOW IT FITS TOGETHER

From .ts source to running JavaScriptType checking and running are separate jobs. Tools that strip types never check them, so tsc --noEmit (or the editor) is the only thing enforcing the types.
  1. Write .ts with typesAnnotations, interfaces, type aliases, generics, satisfies, import type. None of it has runtime behavior if you stay within erasable syntax.
  2. Type check: tsc --noEmitWhole-program analysis in CI and the editor. Since TypeScript 7.0 (July 2026) tsc is the native Go port, roughly 10x faster; TS 6 remains available as tsc6.
  3. Strip typesNode.js (on by default since 22.18 and 23.6, stable since 24.12), Deno, Bun, esbuild, swc and Vite replace types with whitespace. No checking happens here.
  4. Transform non-erasable syntaxenum, namespaces with code, parameter properties and legacy decorators generate runtime code. Node's stripping rejects them; erasableSyntaxOnly makes tsc reject them too.
  5. Plain JavaScript runsTypes, interfaces and generics are gone. typeof and instanceof see only JavaScript values; a User interface cannot be checked at runtime.
  6. Validate at the boundaryfetch responses, JSON.parse, env vars, form data, queue messages and DB rows enter as unknown and become typed only after a schema (zod, valibot, ArkType) parses them.
Top, bottom and object typesBehavior under strict mode. any and unknown both accept every value; only unknown makes you prove what it is before use.
HoldsUsable without narrowingUse it for
anyAnything; checking switched off both waysYes, every operation compiles (and may crash)Migration escape hatch only; it spreads silently
unknownAnythingNo: narrow with typeof, in, instanceof or a schemaUntrusted input, catch variables, generic sinks
neverNothing; no value existsUnreachable code; assignable to every typeExhaustiveness checks, impossible branches, throw-only returns
objectAny non-primitive: objects, arrays, functionsOnly Object.prototype membersConstraints meaning 'not a primitive'
{}Any value except null and undefined, primitives includedAlmost nothingRarely; it does not mean 'empty object'
Record<string, unknown>Objects with string keys; interfaces need an index signatureRead keys, but each value is unknownGeneric JSON-like objects before validation

KEY TERMS

Structural typing
Compatibility is decided by members, not by declared names: any value with the required properties fits, extra properties included. Fresh object literals get an extra excess-property check, which is why the same object passes as a variable and fails inline. Use branded types when two structurally identical types (UserId, OrderId) must not mix.
Type erasure
Types have no runtime existence. You cannot ask whether a value is a User at runtime, generics do not exist in the output, and `as` is an unchecked assertion, not a conversion. Runtime checks must be written in JavaScript, which is why schema libraries derive the static type from a runtime validator (z.infer) rather than the other way around.
Narrowing and discriminated unions
Control-flow analysis refines a type after typeof, instanceof, in, equality checks, truthiness, type predicates and assertion functions. A union of object types sharing a literal tag (status: "pending" | "failed") narrows on that tag, so each branch sees only valid fields, and assigning the leftover to never makes a missing case a compile error.
Generics and constraints
Type parameters relate inputs to outputs (T in, T out) instead of collapsing to unknown. extends constrains what T may be (T extends { id: string }, K extends keyof T), and inference fills them in from arguments. If a type parameter appears only once, it is usually unnecessary.
unknown vs any
Both accept every value. any also lets you do anything with it and can be assigned to everything, so one any silently disables checks downstream. unknown forces a narrowing step before use. Type untrusted data and catch-clause errors as unknown (useUnknownInCatchVariables is part of strict).
satisfies vs annotation
const x: T = value checks the value and then forgets it, giving x the declared type T. value satisfies T checks the same thing but keeps the inferred, narrower type (literal keys, specific members), so you get both the error and the autocomplete.
Utility, mapped and conditional types
Mapped types transform each key of a type (Partial, Readonly, Record, Pick, Omit are mapped types). Conditional types choose a type by a test (T extends string ? A : B) and with infer extract parts (ReturnType, Awaited). Use them to derive types from one source of truth; keep them out of hot paths of everyday code, since deep type-level programs are hard to read and slow to check.

IN YOUR STACK

TypeScript · Discriminated union + never Client + server

type PaymentState =
  | { status: "idle" }
  | { status: "pending"; startedAt: number }
  | { status: "succeeded"; receiptId: string }
  | { status: "failed"; error: string; retryable: boolean };

function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}

function buttonLabel(state: PaymentState): string {
  switch (state.status) {
    case "idle":
      return "Pay now";
    case "pending":
      return `Processing since ${new Date(state.startedAt).toLocaleTimeString()}`;
    case "succeeded":
      return `Paid, receipt ${state.receiptId}`; // receiptId exists only here
    case "failed":
      return state.retryable ? `Retry: ${state.error}` : state.error;
    default:
      return assertNever(state); // adding a status without a case fails to compile
  }
}
  • Compare with { status: string; receiptId?: string; error?: string }: every combination is representable, including succeeded with an error, and every read needs a defensive check.
  • assertNever both fails the build when the union grows and throws at runtime if unvalidated data smuggles in an unknown status.
Discriminated union + never documentation (opens in new tab)

TypeScript · zod 4 at an API boundary Client

import { z } from "zod";

const Order = z.object({
  id: z.uuid(),
  email: z.email(),
  totalCents: z.number().int().nonnegative(),
  status: z.enum(["open", "paid", "refunded"]),
  createdAt: z.iso.datetime(),
});
export type Order = z.infer<typeof Order>; // the type is derived from the check

export async function fetchOrder(id: string): Promise<Order> {
  const res = await fetch(`https://api.example.com/orders/${encodeURIComponent(id)}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const body: unknown = await res.json(); // json() returns any; refuse to trust it
  const result = Order.safeParse(body);
  if (!result.success) {
    throw new Error(`Invalid order payload:\n${z.prettifyError(result.error)}`);
  }
  return result.data; // typed, and actually checked
}
  • Without the parse, res.json() as Order compiles and lies: a renamed field or a null becomes a crash far from the request, or silently wrong data.
  • Validate everything that crosses a trust boundary: HTTP bodies on the server, third-party responses, env vars at startup, localStorage, queue messages and webhook payloads.
  • z.object strips unknown keys by default; use z.strictObject to reject them. safeParse returns a discriminated union, so result.data only exists after checking success.
zod 4 at an API boundary documentation (opens in new tab)

TypeScript · Generics with constraints Client + server

// K is tied to T, so the result is T[K][] rather than unknown[]
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

// Constraint on shape: anything with a string id, structurally
function indexById<T extends { id: string }>(items: readonly T[]): Map<string, T> {
  return new Map(items.map((item) => [item.id, item]));
}

type User = { id: string; name: string; age: number };
const users: User[] = [{ id: "u1", name: "Ada", age: 36 }];

const names = pluck(users, "name"); // string[]
const ages = pluck(users, "age");   // number[]
const byId = indexById(users);      // Map<string, User>, the full type survives
// pluck(users, "email");           // error: not "id" | "name" | "age"

// const type parameter: infer literal types without as const at the call site
function defineRoles<const R extends readonly string[]>(roles: R): R {
  return roles;
}
const roles = defineRoles(["admin", "editor"]); // readonly ["admin", "editor"]
type Role = (typeof roles)[number];             // "admin" | "editor"
  • Constraints describe what the function needs; the type parameter preserves what the caller has. indexById(users) returns User values, not just { id: string }.
  • A type parameter used only once (function log<T>(x: T): void) adds nothing over unknown. Generics earn their place by linking two positions.
Generics with constraints documentation (opens in new tab)

TypeScript · satisfies vs annotation Client + server

type Route = { path: `/${string}`; auth: boolean };

// Annotation: checked, then widened to the declared type
const annotated: Record<string, Route> = {
  home: { path: "/", auth: false },
  settings: { path: "/settings", auth: true },
};
annotated.setings; // no error: any string key is allowed by the annotation

// satisfies: same check, but the inferred type is kept
const routes = {
  home: { path: "/", auth: false },
  settings: { path: "/settings", auth: true },
  // admin: { path: "admin", auth: true }, // error: path must start with "/"
} satisfies Record<string, Route>;

// routes.setings;                   // error: property does not exist
type RouteName = keyof typeof routes; // "home" | "settings"

// Combine with as const to keep literal values as well as keys
const flags = { beta: true, rollout: 0.25 } as const satisfies Record<string, boolean | number>;
type Flags = typeof flags; // { readonly beta: true; readonly rollout: 0.25 }
  • Use an annotation when you want the wider type on purpose (a mutable variable that will hold other routes later); use satisfies for config and lookup tables you want checked and precisely typed.
  • satisfies never changes runtime behavior and, unlike as, cannot make an invalid value compile. This codebase validates every content module with `satisfies Explainer` for exactly that reason.
satisfies vs annotation documentation (opens in new tab)

TypeScript · Branded (nominal) types Client + server

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
// The only way in is a function that checks; the brand costs nothing at runtime
function toUserId(raw: string): UserId {
  if (!/^usr_[a-z0-9]{12}$/.test(raw)) throw new Error(`Invalid user id: ${raw}`);
  return raw as UserId;
}
function toOrderId(raw: string): OrderId {
  if (!raw.startsWith("ord_")) throw new Error(`Invalid order id: ${raw}`);
  return raw as OrderId;
}
function toCents(amount: number): Cents {
  if (!Number.isSafeInteger(amount)) throw new Error("Cents must be an integer");
  return amount as Cents;
}

function refund(user: UserId, order: OrderId, amount: Cents): void { /* ... */ }
refund(toUserId("usr_abc123def456"), toOrderId("ord_1"), toCents(1999)); // OK
// refund(toOrderId("ord_1"), toUserId("usr_abc123def456"), toCents(1999)); // error
// refund(userId, orderId, 19.99); // error: number is not Cents
  • Structural typing treats every string as interchangeable; a brand adds a phantom property that only exists in the type system, making swapped arguments a compile error.
  • Keep the as casts inside the constructor functions. zod can produce the same thing from a schema: z.string().regex(...).brand<"UserId">().
Branded (nominal) types documentation (opens in new tab)

JSONC · tsconfig for Node type stripping Server

// tsconfig.json for code Node runs directly: node src/server.ts
{
  "compilerOptions": {
    "noEmit": true,                         // tsc only checks; Node strips at runtime
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true, // imports keep their .ts extension
    "erasableSyntaxOnly": true,             // reject enum, namespaces, parameter properties
    "verbatimModuleSyntax": true,           // type-only imports must say import type
    "types": ["node"],                      // TS 6+ no longer loads every @types package
    "strict": true,                         // default since TS 6; keep it explicit
    "noUncheckedIndexedAccess": true,       // arr[i] and record[key] may be undefined
    "exactOptionalPropertyTypes": true,     // x?: T does not accept an explicit undefined
    "noImplicitOverride": true,
    "skipLibCheck": true
  }
}
  • Node.js strips types by default since 22.18.0 and 23.6.0, and the feature is stable since 24.12.0 and 25.2.0. It never type-checks and ignores tsconfig, so run tsc --noEmit in CI.
  • strict bundles strictNullChecks, noImplicitAny, strictFunctionTypes and useUnknownInCatchVariables; the two index and optional flags above are not part of it and catch real bugs.
  • TypeScript 7.0 removed options deprecated in 6.0 (baseUrl, moduleResolution node10 and classic, target es5) and ships no programmatic API until 7.1, so tools that embed the compiler may still pin TypeScript 6 via @typescript/typescript6.
tsconfig for Node type stripping documentation (opens in new tab)

WHERE IT BITES

  • Casting at the boundary: res.json() as User, JSON.parse(x) as Config, or a generic fetch<T>() that never validates. The types say safe; production data disagrees later and far away.
  • Letting any leak in through untyped libraries, JSON.parse or catch (e) in older configs. One any flows through assignments and switches off checks everywhere it reaches; turn on noImplicitAny and lint against explicit any.
  • Expecting types at runtime: checking value instanceof SomeInterface, relying on a generic parameter inside the function body, or assuming an enum exists in stripped code (Node's type stripping rejects enums).
  • Optional-field soup instead of unions: { loading?: boolean; data?: T; error?: E } allows loading with data and error at once. Model states as a discriminated union.
  • Running only a type stripper (Node, Bun, esbuild, Vite) and assuming the code was type-checked. Stripping succeeds on code full of type errors.
  • Assuming arr[i] or record[key] is defined. Without noUncheckedIndexedAccess the type omits undefined, which is the most common source of 'cannot read properties of undefined' in typed code.

CLOSE THE AI. EXPLAIN THIS.

A typed API client returns Promise<Invoice>, yet production throws 'cannot read properties of undefined (reading amount)'. Trace where the type stopped being true, why the compiler could not catch it, and which two changes (one at the boundary, one in tsconfig) would have.

SOURCES

Explainer reviewed