TypeScript’s Type System Is a Language You Haven’t Learned Yet

January 27, 202611 min readAndrew Fribush
TypeScriptType SystemAdvanced PatternsSoftware Design

Most TypeScript in production codebases is annotation. Developers label function signatures, tag variables with types, and let the compiler catch null references and misspelled property names. That captures about 20% of the language's value. The other 80% lives in the type system itself, which is Turing-complete and capable of expressing constraints that would require hundreds of lines of runtime validation to replicate.

The phrase "make illegal states unrepresentable" originated in the Elm community, where Evan Czaplicki and others demonstrated that a well-designed type system could eliminate whole classes of runtime errors by construction rather than by testing. TypeScript can achieve the same result, though it takes more deliberate effort. The defaults favor permissiveness over safety. The effort pays for itself. Once business rules are encoded into types, refactoring stops being a prayer and starts being a conversation with the compiler. A type system used only for labeling catches typos. A type system used for design catches logic errors.

Making Invalid States Unrepresentable

A production crash at 2 AM. The root cause: a new engineer rendered data.items without checking the loading flag, and the application blew up on a slow 3G connection two weeks after the code shipped. The data model was a flat object with a loading boolean, a nullable data field, and a nullable error field. Nothing prevented loading: true and data: someValue from coexisting. Nothing prevented error and data from both being populated simultaneously. The model allowed states that had no meaning, and the code reached one.

This is the most common bug pattern in frontend code. Not syntax errors. Not off-by-one mistakes. Impossible states that the data model silently permits.

Discriminated unions fix it. Each state becomes a distinct type, connected by a shared literal field (the discriminant) that TypeScript narrows on automatically. The loading state does not have a data property because the loading state should never have one. The constraint is structural, not conventional.

TypeScript
// Each state is its own type with a literal discriminanttype ApiState<T> =  | { status: 'loading' }  | { status: 'success'; data: T }  | { status: 'error'; error: string; retryable: boolean };// The compiler narrows the type based on the discriminantfunction renderUsers(state: ApiState<User[]>) {  switch (state.status) {    case 'loading':      return showSpinner();    case 'success':      // TypeScript knows state.data exists here (narrowed automatically)      return state.data.map(u => renderUserCard(u));    case 'error':      // TypeScript knows state.error and state.retryable exist      return showError(state.error, state.retryable);  }}// Exhaustiveness checking: if you add a new state and miss it,// this helper turns the gap into a compile errorfunction assertNever(x: never): never {  throw new Error(`Unexpected state: ${x}`);}// Adding 'retrying' to the union without updating the switch// causes the default case to fail: x is not assignable to neverfunction renderUsersExhaustive(state: ApiState<User[]>) {  switch (state.status) {    case 'loading':      return showSpinner();    case 'success':      return state.data.map(u => renderUserCard(u));    case 'error':      return showError(state.error, state.retryable);    default:      return assertNever(state);  }}

The assertNever function transforms discriminated unions from a convenience into a guarantee. Add a fourth status to ApiState and forget to handle it in the switch, and TypeScript produces a compile error: the new state flows into the default case where never is expected. The compiler has proven the switch is exhaustive. Without that proof, the missing case surfaces when a user triggers the unhandled state in production. That is not a hypothetical. That was the 2 AM crash.

In 1987, David Harel published the paper that introduced Statecharts, demonstrating that complex reactive systems could be made predictable by explicitly enumerating states and transitions. A discriminated union is a Statechart that the compiler verifies on every build. Missing a state becomes a type error, not a production bug. Missing a transition becomes a compile failure, not a customer support ticket.

The pattern applies anywhere state is managed explicitly: Redux actions, useReducer dispatch types, WebSocket message handlers, workflow engines. Any domain where a finite set of possibilities must be handled completely. Handle all the cases, or the code does not compile. That is the deal.

Making Mixups Impossible

The NASA Mars Climate Orbiter is the canonical cautionary tale. In 1999, Lockheed Martin's ground software produced thruster data in pound-force seconds. NASA's navigation software expected newton-seconds. Both values were floating-point numbers. The mismatch was never caught. The spacecraft entered the atmosphere at the wrong angle and burned up, destroying a $327 million mission. The root cause was a type confusion that no structural type system would flag, because structurally, the values were identical.

TypeScript uses structural typing: if two types have the same shape, they are interchangeable. That is usually a feature. It becomes a liability when two values share the same underlying type but represent fundamentally different things. A UserId and an OrderId are both number. A Dollars amount and a Cents amount are both number. The compiler will happily let you pass one where the other is expected, and you discover the mistake when an order lookup returns the wrong user or a financial calculation is off by a factor of 100.

Branded types prevent exactly this class of error. A phantom intersection forces TypeScript to distinguish between structurally identical values, with zero runtime cost.

TypeScript
// Branded types: a phantom intersection that prevents mixupstype Brand<T, B extends string> = T & { readonly __brand: B };type UserId = Brand<number, 'UserId'>;type OrderId = Brand<number, 'OrderId'>;type Cents = Brand<number, 'Cents'>;type Dollars = Brand<number, 'Dollars'>;// Construction functions are the only entry pointfunction UserId(id: number): UserId { return id as UserId; }function OrderId(id: number): OrderId { return id as OrderId; }function Cents(amount: number): Cents { return amount as Cents; }function Dollars(amount: number): Dollars { return amount as Dollars; }// Consumers get compile-time safety everywherefunction fetchUser(id: UserId): Promise<User> { /* ... */ }function fetchOrder(id: OrderId): Promise<Order> { /* ... */ }function formatPrice(amount: Cents): string {  return `$${(amount / 100).toFixed(2)}`;}// The compiler catches the mistake at every callsiteconst userId = UserId(42);const orderId = OrderId(42);fetchUser(userId);    // compilesfetchUser(orderId);   // ERROR: OrderId is not assignable to UserIdfetchUser(42);        // ERROR: number is not assignable to UserId// Prevents unit confusion in financial codeconst price = Cents(4999);const tax = Dollars(3);formatPrice(price);   // compiles: Cents expected, Cents providedformatPrice(tax);     // ERROR: Dollars is not assignable to Cents

The __brand property never exists at runtime. It is a compile-time fiction that forces the type system to treat UserId and OrderId as distinct. The construction functions contain the single as assertion in the entire system. After that boundary, every function that accepts a UserId is impossible to call with an OrderId, a raw number, or any other lookalike value. The ceremony happens once at the boundary; the safety propagates to every callsite in the codebase.

Branded types protect individual values. The next rung of the ladder is protecting entire structures. Mapped types do this by derivation rather than duplication. When you manually write a PartialUser next to your User type, you create a maintenance coupling that the compiler cannot see. When you derive Partial<User> from User, the relationship is explicit and the compiler enforces consistency automatically.

TypeScript
interface User {  id: number;  name: string;  email: string;  role: 'admin' | 'editor' | 'viewer';  lastLogin: Date;}// Make every property nullabletype Nullable<T> = { [K in keyof T]: T[K] | null };// Extract only the string-valued propertiestype StringProps<T> = {  [K in keyof T as T[K] extends string ? K : never]: T[K];};// Create a type for form fields: each field has value + errortype FormFields<T> = {  [K in keyof T]: { value: T[K]; error: string | null; touched: boolean };};// Derived types update automatically when User changestype NullableUser = Nullable<User>;// { id: number | null; name: string | null; email: string | null; ... }type UserStringFields = StringProps<User>;// { name: string; email: string } (role excluded; it's a union, not plain string)type UserForm = FormFields<Pick<User, 'name' | 'email' | 'role'>>;// { name: { value: string; error: string | null; touched: boolean }; ... }// A practical DeepReadonly that freezes nested objectstype DeepReadonly<T> = {  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];};const config: DeepReadonly<AppConfig> = loadConfig();config.database.host = 'new-host'; // ERROR: cannot assign to readonly property

A DeepReadonly type applied to a configuration object means the compiler rejects any mutation anywhere in the object graph, at any depth. Achieving the same guarantee with Object.freeze requires runtime cost, applies only shallowly, and does not protect against a frozen object returning a mutable reference to nested data. The compile-time version is both free and complete.

Conditional types add branching logic to the type level. The syntax mirrors a ternary expression: A extends B ? C : D. The infer keyword, TypeScript's pattern-matching mechanism, lets you extract types from inside other types. The ReturnType, Parameters, and Awaited utility types in TypeScript's standard library are all built with infer.

TypeScript
// Conditional types: type-level if/elsetype IsString<T> = T extends string ? true : false;type A = IsString<'hello'>;  // truetype B = IsString<42>;       // false// infer: extract types from inside other typestype Unwrap<T> = T extends Promise<infer U> ? U : T;type C = Unwrap<Promise<string>>;  // stringtype D = Unwrap<number>;           // number (passthrough)// Extract the return type of any functiontype Return<T> = T extends (...args: any[]) => infer R ? R : never;// Practical: extract the resolved type from an async functiontype UserData = Unwrap<ReturnType<typeof fetchUser>>;// If fetchUser returns Promise<User>, UserData is User// Distributive conditional types: operate over each union membertype NonNullableProps<T> = {  [K in keyof T]: T[K] extends null | undefined ? never : T[K];};// Combine infer with template literals for string parsingtype ExtractRouteParams<T extends string> =  T extends `${string}:${infer Param}/${infer Rest}`    ? Param | ExtractRouteParams<Rest>    : T extends `${string}:${infer Param}`      ? Param      : never;type Params = ExtractRouteParams<'/users/:userId/posts/:postId'>;// 'userId' | 'postId' (extracted at compile time)

That route parameter extraction takes a string literal type like '/users/:userId/posts/:postId' and produces the union 'userId' | 'postId', entirely at compile time, with no runtime code. The route definition becomes the single source of truth for both the URL pattern and the parameter types. When types are derived from data, changing the source means the compiler finds every place the change matters. When types are manually maintained, changing the source means hoping.

Template literal types extend this to strings generally. Where TypeScript previously treated string values as opaque string types, template literals give strings internal structure that the compiler can check and combine.

TypeScript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';type ApiVersion = 'v1' | 'v2';type Resource = 'users' | 'orders' | 'products';// All valid endpoint strings, generated combinatoriallytype Endpoint = `/api/${ApiVersion}/${Resource}`;// "/api/v1/users" | "/api/v1/orders" | ... | "/api/v2/products" (12 total)// A type-safe request functionfunction request(method: HttpMethod, endpoint: Endpoint): Promise<Response> {  return fetch(endpoint, { method });}request('GET', '/api/v1/users');      // compilesrequest('GET', '/api/v3/users');      // ERROR: not a valid Endpointrequest('GET', '/api/v1/invoices');   // ERROR: not a valid Endpoint// CSS values with compile-time unit validationtype CSSLength = `${number}px` | `${number}rem` | `${number}em`;type CSSColor = `#${string}` | `rgb(${number}, ${number}, ${number})`;function setStyle(width: CSSLength, color: CSSColor): void { /* ... */ }setStyle('16px', '#ff0000');        // compilessetStyle('16', '#ff0000');          // ERROR: '16' is not CSSLengthsetStyle('16px', 'red');            // ERROR: 'red' is not CSSColor// Event name patterns with template literalstype DomainEvent = `${string}:${'created' | 'updated' | 'deleted'}`;function onDomainEvent(event: DomainEvent, handler: () => void): void { /* ... */ }onDomainEvent('user:created', handleIt);   // compilesonDomainEvent('order:updated', handleIt);  // compilesonDomainEvent('user:archived', handleIt);  // ERROR: not a valid DomainEvent

JavaScript has been called "stringly-typed" for good reason. API endpoints, event names, CSS values, configuration keys, and database column names are all plain string, which means a typo in any of them becomes a runtime error that basic typing cannot catch. Template literal types turn the most common string bugs (misspelled event names, malformed URLs, incorrect unit suffixes) into compile errors. The difference between a bug that shows up in a Sentry alert and one that shows up in the editor as a red squiggle is the difference between an hour of debugging and three seconds of reading.

Notice the arc so far. Discriminated unions catch state bugs. Branded types catch value bugs. Mapped, conditional, and template literal types catch structural bugs. Each rung of the ladder asks the type system to do more, and each rung eliminates a category of error that the previous one could not reach.

The Library Author's Trick

Advanced TypeScript patterns front-load type complexity in the library author's code so that every consumer writes simple, safe, autocompletable code. The complexity does not disappear. It migrates from scattered runtime checks into a single, well-tested type definition. A type-safe event emitter demonstrates the principle cleanly.

The event map (a plain interface mapping event names to payload shapes) is defined once. The emitter class is written once. Every on and emit call across the entire application is then type-checked against that single source of truth. The consumer never writes a type annotation. The compiler infers everything from the event name.

TypeScript
// The event contract: names mapped to payload shapesinterface AppEvents {  'user:login': { userId: string; timestamp: Date };  'user:logout': { userId: string };  'order:created': { orderId: string; items: number; total: Cents };  'order:shipped': { orderId: string; trackingNumber: string };  'payment:failed': { orderId: string; reason: string; retryable: boolean };}// A fully type-safe emitter: 20 lines of type machinery// that protects every callsite in the codebaseclass TypedEmitter<Events extends Record<string, unknown>> {  private listeners = new Map<keyof Events, Set<Function>>();  on<E extends keyof Events>(    event: E,    handler: (payload: Events[E]) => void  ): () => void {    if (!this.listeners.has(event)) {      this.listeners.set(event, new Set());    }    this.listeners.get(event)!.add(handler);    // Return an unsubscribe function    return () => this.listeners.get(event)?.delete(handler);  }  emit<E extends keyof Events>(event: E, payload: Events[E]): void {    this.listeners.get(event)?.forEach(fn => fn(payload));  }  // Type-safe "once": handler auto-removes after first call  once<E extends keyof Events>(    event: E,    handler: (payload: Events[E]) => void  ): void {    const unsub = this.on(event, (payload) => {      unsub();      handler(payload);    });  }}const bus = new TypedEmitter<AppEvents>();// Payload types are inferred from the event name (no annotation needed)bus.on('user:login', (data) => {  console.log(data.userId);       // string (compiler knows)  console.log(data.timestamp);    // Date (compiler knows)});bus.on('payment:failed', (data) => {  if (data.retryable) {           // boolean (compiler knows)    retryPayment(data.orderId);   // string (compiler knows)  }  logFailure(data.reason);        // string (compiler knows)});// Correct emission: all fields match the contractbus.emit('order:shipped', {  orderId: 'abc-123',  trackingNumber: '1Z999AA10123456784',});// Incorrect emission: compile errorbus.emit('order:shipped', {  orderId: 'abc-123',  // ERROR: Property 'trackingNumber' is missing});// Wrong payload shape: compile errorbus.emit('user:login', {  userId: 'u-42',  // ERROR: Property 'timestamp' is missing});

Every on call has full autocomplete and payload type inference. Adding a new event means adding one line to the AppEvents interface; the compiler verifies that every existing handler and emission remains consistent. Removing an event means deleting one line, and the compiler highlights every callsite that references it. The event map is the contract. The compiler enforces the contract continuously.

Prisma's query builder, tRPC's end-to-end type safety, and Zod's schema-to-type inference all work this way. The library author absorbs the type complexity. The consumer writes code that looks simple and happens to be correct.

But the same power that makes these libraries elegant can produce the worst TypeScript codebases when over-applied. The worst codebases are not the ones with too few types. They are the ones with types so intricate that nobody on the team can read, modify, or debug them.

any Is a Bug, Not a Type

Every any annotation is a hole in the type system. The keyword does not mean "this value could be anything." It means "the compiler should stop checking here." And any is infectious: a function that returns any poisons every variable that touches its return value, silently disabling type checking across call chains. Google's internal TypeScript style guide bans any entirely, requiring unknown as the replacement. The difference: unknown forces validation before use. That forced validation is the point.

Type Assertions Without Validation

Writing data as User is a promise to the compiler. If the data came from a network response, a database query, or any external source, the promise is unsupported by evidence. Runtime validation libraries (Zod, io-ts, Valibot, ArkType) turn that unsupported promise into a verified fact. The data either matches the schema or you get a structured error at the system boundary, not an undefined is not a function buried three call frames deep. Type assertions have a place: narrowing values where TypeScript's control flow analysis falls short, or working with legacy APIs that lack proper types. Outside those narrow cases, a type assertion is a bet that the shape of external data will never change. That bet loses.

Over-Engineered Types

A type definition that takes longer to read than the function it constrains has traded one category of defect for another category of maintenance cost. Pull requests containing 60-line recursive conditional types do get written. They technically prevent a narrow class of misuse. Nobody else on the team can read, modify, or debug them when they produce inscrutable error messages. Recursive conditional types and deeply nested infer chains are sometimes necessary (Prisma's type definitions could not exist without them), but they should be isolated behind clean interfaces, documented, and treated as infrastructure that most engineers consume without needing to modify. If every engineer on the team must understand the implementation to use the type safely, you have not reduced complexity. You have relocated it.

The tension is real. The same type system that lets a library author build a perfectly safe API lets an application developer write types that are harder to understand than the runtime code they constrain. The goal is not maximum type safety at any cost. The goal is maximum safety that remains readable. Discriminated unions, branded types, and straightforward mapped types cover the overwhelming majority of real-world value. Everything beyond them should earn its complexity.

The Compiler as Co-Designer

Every bug has a discovery cost, and that cost increases by roughly an order of magnitude at each stage of the development lifecycle. A type error caught at write time costs seconds. A failing unit test costs minutes. A code review comment costs hours, because the author has moved on and must rebuild context. A production incident costs days: investigation, triage, hotfix, postmortem, reputation repair. Steve McConnell documented this cost curve in Code Complete, and Barry Boehm's earlier work on the COCOMO model reached the same conclusion: the cost of fixing a defect in production is 10 to 100 times higher than fixing it during development. Those ratios have held up across decades of software engineering research.

Advanced TypeScript moves the discovery of entire bug categories to the cheapest possible stage: the moment the code is written. Discriminated unions prevent unhandled states. Branded types prevent value confusion. Mapped and conditional types prevent structural drift. Template literal types prevent stringly-typed errors. Each pattern converts a class of runtime failure into a compile-time rejection.

Three things to learn, in order. Discriminated unions first: they turn state management from a source of runtime surprises into exhaustiveness checking that the compiler performs on every build. Branded types second: they prevent the subtle ID and value mixups that cause the bugs hardest to trace in production. Mapped types third: they keep type definitions in sync with data sources, so that a change in one place propagates correctly instead of silently diverging. Those three patterns deliver approximately 80% of the real-world value of advanced TypeScript. Everything beyond them is useful but secondary.

A type system used only for annotation catches typos. A type system used for design catches logic errors. The compiler is not a gatekeeper. It is a co-designer that works every build, reviews every change, and never loses context on the codebase. Use it.

Andrew Fribush

Software engineer building production AI, commerce, and public-data systems. More about Andrew →