TypeScript’s real power appears when you move beyond annotating parameters and start encoding business rules into the type system itself. The patterns below have eliminated entire categories of bugs from my codebases — the kind that unit tests rarely catch.
From Discriminated Unions to Branded Types
Discriminated unions are the workhorse: model each state of your application explicitly, and the compiler forces every consumer to handle every case. Combine them with exhaustive switch checks and impossible states simply stop compiling — no more “loading is true but data is also set” bugs.
Branded types take it further by making primitives unmistakable. A UserId and an OrderId are both strings at runtime, but branding them means you can never pass one where the other belongs. Add template literal types for route safety and const assertions for precise inference, and your codebase becomes genuinely bulletproof.
Discriminated Unions in Practice
The canonical example is request state, and it deserves its reputation. Model a fetch as four distinct shapes — idle, loading, success carrying data, and failure carrying an error — joined by a literal status field, and an entire family of UI bugs becomes unrepresentable. The component cannot read data during loading because the loading arm of the union simply does not have a data property. The compiler is no longer checking your types; it is enforcing your product logic.
The pattern generalizes far beyond fetching. Form workflows, wizard steps, editor modes, payment lifecycles, WebSocket connection states — anywhere your code has an implicit state machine, a discriminated union makes it explicit. My rule of thumb: the moment I see two boolean flags that interact, like isLoading and hasError, I replace them with one union. Four booleans can express sixteen combinations; the business almost always means only four of them.
Exhaustiveness Checking
The union’s payoff arrives at maintenance time, but only if you wire up exhaustiveness. End every switch over the discriminant with a default arm that assigns the value to the never type. Six months later, when someone adds a refund state to the payment union, the compiler walks them to every switch in the codebase that now fails to handle it. That single trick converts “search the repo and hope” into a mechanical, guaranteed-complete refactor.
Branded Types for Domain Safety
Structural typing is TypeScript’s superpower and its trapdoor: any string is assignable to any other string, which means a function accepting a userId will cheerfully receive an orderId, an email address, or the contents of a query parameter. Branding closes the trapdoor by intersecting a primitive with a phantom marker property that exists only at compile time. A UserId and an OrderId remain plain strings at runtime, with zero overhead, but the compiler now refuses to confuse them.
The technique earns its keep at scale. In a codebase with a dozen entity types, identifier mix-ups are not hypothetical — they are the class of bug that passes every unit test and corrupts data in production, because the code is perfectly valid and perfectly wrong. After branding the identifiers in one client project, the compiler surfaced three latent swaps in the first week: two in test fixtures, one in a cache key that had been silently colliding for months.
Validating at the Boundary
Brands are only trustworthy if they are minted honestly. The discipline is to create branded values exclusively inside constructor functions that validate first — parseUserId checks the format and returns a UserId or throws, and nothing else in the codebase is allowed to cast. Pair this with a schema library at the API boundary and you get an architecture where unvalidated data is visibly typed as unknown until it passes through a parser. Validate once at the edge, then trust the types everywhere inside.
Template Literal Types
Template literal types let the compiler understand string structure, and the killer application is routing. Type your paths as a union built from templates — with parameters like /users/${string} — and a typo in a navigation call becomes a compile error instead of a 404 discovered by a customer. Extend the same idea to extract parameter names from the path, and your route helper can demand exactly the params each route needs, no more and no fewer.
The pattern shows up anywhere strings carry structure: event names like user:created, translation keys, CSS custom properties, permission strings like posts:write. I once typed a pub-sub layer so that subscribing to an event name automatically inferred the payload type for the handler — deleting an event from the map instantly flagged every stale subscriber in the codebase. That is documentation that cannot go out of date, generated from types that were already there.
Const Assertions and the satisfies Operator
The as const assertion is the difference between a config object typed as “a bag of strings and numbers” and one whose every value is a precise literal the compiler can reason about. Freeze a theme, a feature-flag map, or a navigation tree with as const, derive unions from its keys with keyof typeof, and the configuration becomes its own source of truth — add an entry and every dependent type updates; remove one and every consumer that still references it turns red.
The satisfies operator, added in TypeScript 4.9, completes the picture by separating validation from widening. Where an annotation forces a value into a broader type and discards what the compiler learned, satisfies checks conformance while preserving the narrow inference. Config objects are the textbook case: you want assurance that every entry matches the schema and you want each entry’s literal type to survive. Before satisfies you had to choose; now the answer is simply both.
Generics That Earn Their Complexity
Generics have a reputation problem because they are so often used to show off rather than to communicate. My bar is simple: a type parameter must preserve information that would otherwise be lost. A function that accepts T and returns T[] earns its generic; a function whose T appears exactly once in the signature is usually just an unknown wearing a costume. When a signature needs three type parameters and a conditional type to describe, I first ask whether the runtime design is the real problem.
Constrained generics are where the feature becomes an API-design tool. A repository whose methods are typed against T extends Entity, a form builder whose field names are keyof T, an emitter whose payloads follow the event map — in each case the generic lets one implementation serve every domain type without sacrificing an ounce of precision. Library code should spend complexity so application code can be simple; that is the trade that justifies the angle brackets.
Inference Over Annotation
A related discipline: let the compiler do its job. Annotate function boundaries — parameters and return types, where the contract lives — and let inference handle locals, callbacks, and intermediate values. Over-annotation is not just noise; it actively hides bugs, because a hand-written annotation can assert a type the value no longer has, while an inferred type is always the truth. The best TypeScript reads almost like JavaScript, with the types visible only where they carry intent.
Utility Types Worth Memorizing
Beyond the famous Pick and Omit, three built-ins do outsized work. Parameters and ReturnType let you derive types from functions you do not own, which keeps third-party integrations honest without hand-copying signatures. Extract and Exclude carve precise subsets out of unions — pull just the error variants from an event type and your logger’s signature writes itself. And Readonly at the boundary of a module documents, enforceably, which side owns mutation.
Making It Stick on a Team
Patterns only pay off when the whole team applies them, and the path there is paved with tooling rather than lectures. Turn on strict mode and noUncheckedIndexedAccess in the compiler, ban explicit any and non-null assertions in the linter, and the defaults quietly do the teaching. For the judgment calls tooling cannot enforce, we keep a one-page playbook — unions for state machines, brands for identifiers, satisfies for config — with a before-and-after snippet for each, so review comments can link to a pattern instead of restarting a debate.
Migration deserves the same pragmatism. On a large legacy codebase, strictness arrives module by module: new code is born strict, and every touched file gets upgraded as part of the change that touches it. Teams that attempt the big-bang strictness flag generate a four-thousand-error wall, declare bankruptcy, and learn to ignore the compiler — the exact opposite of the goal. Ratchets beat mandates; the error count should only ever go down.
Where to Go From Here
Pick one pattern and apply it to one real module this week — the fetch state you juggle with booleans, the identifier you keep passing as a bare string, the config object that deserves satisfies. Advanced TypeScript is not learned from type-golf puzzles; it is learned by noticing a category of bug you are tired of fixing and encoding its impossibility. Do that a handful of times and the compiler stops feeling like a gatekeeper and starts feeling like the most thorough reviewer on the team.
And when you want to stretch, read the type definitions of the libraries you already use — Zod, tRPC, and TanStack Query are masterclasses in applied type-level design. Reading them teaches something no tutorial does: every advanced feature in this post exists to serve an API that feels effortless from the outside. That is the standard worth aiming at — types so good the people consuming them never have to think about types at all.