TypeScript Type System Patterns That Replace Runtime Validation
Encode validation rules in types so the compiler catches invalid states before code runs.

A payment flow breaks in production because one field stored the string "pending" and another checked for "PENDING". The bug shipped, and nobody caught it until an order sat stuck in the wrong state. This is the pattern this piece is about: when correctness depends on a developer remembering to run the right check by hand, that check eventually gets skipped, duplicated wrong, or quietly left behind while the rest of the code moves on. TypeScript types don't survive into the JavaScript that actually runs. Every interface, type alias, and generic signature gets erased during compilation, so any assumption about shape that isn't backed up by a real runtime check can drift away from what the type system promised without anyone noticing.
This drift appears when a team writes an interface describing what a piece of data should look like, then writes a separate function somewhere else that checks the data at runtime. Then the field in the interface changes, the validation function doesn't get updated to match, and the compiler has nothing to say about it, because the compiler only ever looked at the interface, not the validation logic. The mismatch appears later as a crash, not as a build error.
There's a second, sneakier version of the same failure. A developer writes a function meant to check whether a value matches a shape, and it returns a plain boolean. But because its return type is just boolean instead of a type predicate, TypeScript learns nothing from the check. The validation ran, the type system never found out, and the check exists in the code while doing no work for the compiler.
The order-status bug is the clearest version of this category of mistake. "pending" and "PENDING" are both valid strings, so a type of string accepts either one without complaint. The real fix is a type that only allows the values that are actually meant to exist, so the invalid state can't be written down. That's the shift the rest of this piece works through: patterns that move specific categories of bugs out of runtime checks and into the compiler, where they get caught before the code ever runs.
What the type system can and cannot own
Pushing validation into the type system has a real boundary, and it's worth drawing before going further, because the patterns that follow only work inside it. The type system can own anything the program controls completely: the internal logic, the rules for how one state transitions to another, the constraints on values the program builds or transforms itself. None of that depends on trusting an outside source, so the compiler can enforce it with total confidence.
Anything that comes from outside the program sits on the other side of that line. A TypeScript interface can describe what an API response is supposed to look like. It cannot stop the API from sending something else. That gap needs an actual runtime check, every time, with no exception.
The architecture that follows from this is two layers, not one. Data gets validated at the boundary, the moment it enters the program, using a real runtime check, typically through a schema library. Once that validation has happened, the result gets marked in a way the type system can track, so every function deeper in the program can trust that the data is already correct without re-checking it. Every pattern covered from here on is a tool for one of those two jobs: either tightening what the compiler can guarantee about values the program already controls, or making the one-time boundary check actually count for something downstream. Keeping that split in mind is what keeps the advice honest. None of these patterns replace the boundary check. They replace the repeated, scattered, drift-prone checks that would otherwise happen after it.
Discriminated unions: making impossible states unrepresentable
Picture a typical API response handler, the kind that tracks whether a request is loading, succeeded, or failed. A common first attempt types this as a single interface: a status field of 'loading' | 'success' | 'error', plus an optional data field and an optional error field. It looks reasonable. The shape is valid TypeScript and a logical contradiction at the same time.
A discriminated union closes that gap. Instead of one interface with optional fields bolted on, each state gets its own variant, carrying only the fields that actually belong to it: the loading variant has no data and no error, the success variant has data and no error, the error variant has error and no data. No optional chaining, no null checks, no defensive guards scattered through the code that handles the response.
A payment-processing codebase that made this switch, moving from a flat interface to discriminated unions, deleted a large number of defensive null checks in the process. The code got shorter and safer in the same change, which doesn't happen often. That result depends on one design rule: the field used to distinguish the variants has to be a literal type, a specific string or number value, not a broad type like string or number. Narrowing only works when TypeScript can match an exact literal against each case.
The same pattern pays off again when a new state gets added later. A missing case, which used to be a bug waiting to happen in production, becomes a build failure instead, caught before the code ships.
Branded types: encoding domain rules once and trusting them everywhere
Discriminated unions fix shape: which fields exist together. Branded types fix something else: meaning. A string that holds an email address and a string that holds a username look identical to TypeScript, because they're both just strings. Nothing stops one from being passed where the other belongs, and nothing in the type system remembers whether a given string has actually been checked for validity.
A branded type creates a distinction the compiler will enforce, using an intersection type with something like a unique symbol or a phantom __brand property. That property does not exist at runtime, a fiction the compiler maintains to track a fact about where a value came from.
The effect of that fiction is concrete. Every function downstream that accepts an Email gets proof the value was already checked, with nothing more than a type annotation.
The validation function itself still gets written once. After that, every internal function that takes an Email inherits that guarantee for free, with no repeated checking at each call site. That payoff grows with scale: the more places a value gets passed around internally, the more redundant checking a branded type removes, and the pattern matters most where passing an invalid value would cause real data corruption rather than a simple, obvious error. The actual domain check, the regex or parsing logic that confirms the string is shaped like a real email, still belongs at the boundary, in the validation function that produces the branded value to begin with.
The satisfies operator: enforcing shape without losing literal inference
The satisfies operator has been part of TypeScript since version 4.9. It's been sitting there, available, for years, and most TypeScript code still doesn't use it where it would help. The problem it solves is a tradeoff that looks unavoidable until you've seen the fix: annotating a value with a type to enforce its shape also widens the value's literal types, throwing away precision the compiler already had.
Take a configuration object with a field that should always be one of a few specific string values. Any code downstream that depends on the exact literal, say, for routing logic or a feature flag check, loses the precision it needs, and the developer ends up adding runtime checks or type assertions to work around a problem the compiler introduced on its own.
satisfies checks the object against the interface without replacing the compiler's own inference. The shape gets validated: every required field is present, every field matches its expected type. But the type TypeScript actually assigns to the object afterward is the one it inferred directly from the literal values written in the code, not the broader interface used to check it. Every specific string, every true or false, every discriminant value, stays exact. Shape safety and literal precision stop competing with each other.
Combining satisfies with as const produces a configuration object that documents its own shape. The compiler confirms the structure is valid and keeps every property typed as precisely as it was written, with no extra assertions needed anywhere. Same underlying problem as the configuration example, same fix, just applied to a testing context instead of application config.
Template literal types: moving string-shape validation to the compiler
Template literal types take the pattern-matching the compiler already does for object shapes and apply it to the structure of strings themselves. The emitted output contains plain strings and nothing else: no validation logic, no runtime cost, just the compiler's own bookkeeping done and discarded.
That gives a few genuinely useful tools. Route handlers can encode a path pattern like /users/:id/posts/:postId directly in a type, so a typo in a route string fails to compile. CSS-in-TypeScript tooling can validate that a property string is actually a real CSS property, not a misspelling that would silently do nothing in the browser.
React Navigation 7 is a working example of this at a larger scale. A RootStackParamList type maps every screen name in an app to the params that screen expects to receive. Screens typed with NativeStackScreenProps get their route.params fields typed exactly: an articleId typed as string, a source typed as the literal union 'feed' | 'search'. The bug gets caught while writing the navigation call, not months later in a crash report from a user's phone.
The sharpest limit of this pattern is combinatorial. Interpolating a union type with many members into a template literal type multiplies out every combination, and stacking a few such unions together, each with a handful of members, can produce an enormous number of distinct types for the compiler to track. When the goal is validating broad, unpredictable string input rather than a small fixed set of patterns, a branded type backed by an actual runtime validation function is the right tool instead, not a template literal type stretched past what it's good at.
This pattern stops applying where template literal types check strings that exist in the source code being compiled. A string that arrives from an API response was never part of that compilation, so no template literal type ever gets a chance to check it. This pattern validates strings the program writes, not strings the program receives.
Type predicates and assertion functions: writing guards that narrow
A function meant to check whether a value matches a type can be written two different ways, and only one of them teaches the compiler anything. Write isUser(value: unknown): boolean, and the function can be completely correct, returning true for every valid User and false for everything else, and TypeScript will still treat value as unknown after the check runs. The check happened. The type system didn't hear about it.
The fix is in the return type's syntax, not the function's logic. Assertion functions work on the same principle from a different angle. A function declared to return asserts value is NonNullable<T> tells the compiler that it either throws an exception or guarantees the narrowing holds for the rest of the current scope, with no conditional needed at the call site.
The two serve different situations. Assertion functions fit invariants that should never fail in code that's already correct, like confirming a value isn't null right after a check that already guarantees it.
Either syntax creates a contract the function body has to actually keep. The syntax is a promise. Keeping that promise is entirely on the person who wrote the function body.
One practical rule matters for any guard checking data with nested structure, which is most production API responses: check that the parent object exists before checking any of its child properties. Depth-first, parent before child, every time.
Schema libraries at the boundary: choosing the right validation layer
All of the patterns so far protect data once it's already inside the program. Getting it in safely in the first place is a separate job, and it's the one place where a hand-maintained interface sitting next to a hand-maintained validation function causes the most damage, because the two can drift apart for a long time before anyone notices. A schema library fixes that by making the type and the validator the same artifact: define the schema once, derive the static type from it, and when a field in the schema changes, every place in the code that assumed the old shape gets flagged by the compiler automatically.
Zod is the most widely used option in this space, with roughly 31 million weekly npm downloads.
Valibot 1.x takes the opposite tradeoff. For client-side code where every kilobyte shipped to the browser has a cost, that difference makes Valibot the more practical choice.
None of these is the right answer in every context. Validation speed is a real constraint for a high-traffic API handling thousands of requests per second, or for a job processing large datasets in bulk. For a user-facing form or an internal tool handling ordinary traffic, developer experience, how natural the schema is to write and read, outweighs shaving fractions of a millisecond off each validation call.
TypeScript 6.0 features that shift where the boundary sits
TypeScript 6.0 was released on March 23, 2026, and it moves a few things that used to require extra libraries or manual workarounds into the language itself. That matters for a piece like this one: the line between "you need a library for this" and "the compiler already does this" keeps moving, and it's worth knowing which side of the line sits where as of this release.
Branded types stay exactly where they were. The intersection-with-symbol pattern described earlier remains a community convention, not a built-in language feature, and TypeScript 6.0 did not add nominal typing as a first-class capability. Anyone relying on branded types still builds them the same way, by hand, using the same phantom-property trick.
The more significant shift is experimental support for the TC39 Pattern Matching proposal, currently at Stage 1 as of 2026, enabled with "experimentalPatternMatching": true in tsconfig. This moves the kind of exhaustiveness checking that previously required a separate library like ts-pattern closer to something native to the language itself. It's experimental, and Stage 1 proposals can still change substantially before they're finalized, but it signals where the discriminated-union pattern covered earlier is headed as a built-in language feature rather than a pattern developers have to construct by hand.
satisfies, for its part, got no changes in this release. It shipped in TypeScript 4.9 and nothing in TypeScript 6.0 documentation describes any evolution to it. It remains exactly as capable, and exactly as under-used, as it was the day it shipped.