TypeScript patterns that prevent production bugs
The type checker passing is not the same as the bug being impossible. A few patterns I reach for to make whole categories of production failure unwriteable.
Most of the production bugs I have shipped were not bugs at the moment I wrote them. They were states I did not believe the system could reach.
The type checker was green. The tests passed. And then a request arrived in a shape I had assured myself was impossible, and the impossible state turned out to have been one if statement away the whole time.
TypeScript does not prevent bugs. It is not a correctness proof, and it disappears entirely at runtime. But used with a little deliberateness, it does something narrower and more useful: it makes certain bugs impossible to write. The compiler refuses the program. The patterns below are the ones that have actually paid for themselves. Each closed a category of failure I had already been bitten by. None of them are about writing more types for the sake of it.
Make the illegal states impossible to express
This is the highest-leverage pattern I know, and most engineers reinvent the bug it prevents at least once.
Here is the shape I see everywhere:
interface RequestState<T> {
loading: boolean;
data: T | null;
error: Error | null;
}
Three fields, eight combinations, and most of them are nonsense. loading true with data set. error set with data also set. loading and error both true. Each of those nonsense combinations is a real UI bug waiting to be rendered — a spinner laid over stale data, an error message sitting next to a half-loaded list. I have shipped every one of them.
The fix is to stop describing the state as a bag of independent flags and start describing it as the handful of states that are actually real:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
Now data exists only when status is 'success'. You cannot read it in the error branch, because in the error branch it is not there. The bug is not caught — it is unwriteable. The compiler will not let you express the broken state in the first place.
The discipline is to spend the design effort up front working out which states are genuinely reachable, and then to encode exactly those and no others. Most of the bugs I have prevented this way, I prevented before I wrote the feature.
Parse at the boundary; trust nothing that came over a wire
Types are erased at runtime. as User is a promise you make to the compiler that the compiler has no way to check. It believes you. That is the problem.
The classic failure: an upstream API drops a field, renames one, or starts returning null where it used to return a string. Meanwhile your code says const user = await res.json() as User, and three layers deeper user.email.toLowerCase() throws — far from the boundary where the lie was actually told, in a function that had every right to assume email was a string.
So I parse unknown input into a typed value at the edge, and let the inside of the application be genuinely typed:
import { z } from 'zod';
const User = z.object({
id: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member']),
});
const user = User.parse(await res.json());
If the payload is wrong, it fails here, at the boundary, with a message that names the offending field — not somewhere downstream that was entitled to its assumptions. The principle has a name, 'parse, don't validate', and it is worth internalising: validation checks and throws away its findings; parsing turns unknown data into a value whose type means something.
The same applies to everything you did not construct yourself. Environment variables especially. The number of outages I have watched start with a missing env var read as undefined and quietly threaded through the system is not small. Parse those too.
Make the compiler force you to handle the new case
A union type grows a new variant — a new order status, a new event kind, a new payment method. Somewhere, a switch that used to be exhaustive quietly falls through to a default that does the wrong thing. The notification never sends. The new case is treated as 'unknown' and rejected. Nobody notices until a customer does.
The fix is to make the compiler refuse to forget:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function label(status: Order['status']): string {
switch (status) {
case 'pending':
return 'Pending';
case 'shipped':
return 'Shipped';
case 'delivered':
return 'Delivered';
default:
return assertNever(status);
}
}
The day someone adds 'refunded' to the union, this stops compiling — because status in the default branch is no longer never. Every switch that forgot about refunds becomes a build error rather than a support ticket.
This is the kind of work humans are worst at and compilers are best at. 'Find every place this case is handled and make sure the new variant is covered' is an act of memory when you do it by hand, and memory is exactly the thing that fails under deadline. Hand it to the compiler and it becomes mechanical certainty.
Stop letting strings stand in for everything
Consider transfer(fromAccount, toAccount), where both arguments are string. Swap them and the money moves the wrong way, and the compiler is perfectly happy — they are both strings, after all. The same trap sits under every pair of identifiers in your system. A userId and an orgId are both strings. Pass one where the other belongs and you fetch, or update, or delete the wrong record.
Branded types close this off:
type UserId = string & { readonly __brand: 'UserId' };
type OrgId = string & { readonly __brand: 'OrgId' };
At runtime these are still plain strings — the brand costs nothing. But to the compiler a UserId and an OrgId are now different types, and passing one where the other is expected fails to compile. You mint them deliberately at the boundary, where you are already parsing, and from then on the codebase cannot confuse the two.
There is ceremony here, and I do not brand every string in sight. I brand the ones where mixing them up is expensive — identifiers that address real records, monetary amounts, anything where the wrong value is a silent catastrophe rather than a visible error.
Prefer satisfies to as, and turn the strict flags on
These last ones are smaller, but they compound.
as is the compiler's off switch. Every cast is a place where you have told TypeScript to stop helping you. Most of the worst type-related bugs I have chased hid behind an as that was either wrong the day it was written or quietly became wrong later, when the underlying shape moved and the cast kept insisting otherwise.
satisfies gives you the check without the lie. It verifies a value against a type without throwing away what the value actually is:
const config = {
port: 3000,
host: 'localhost',
} satisfies ServerConfig;
If config is missing a required field, this fails. But config.port is still known to be the literal 3000, not widened to number. You get the guarantee and keep the precision.
And then there are the compiler flags, which are less a pattern than a decision to let the tool do its job. strict should be on; that part is no longer interesting. The one I would single out is noUncheckedIndexedAccess, which makes arr[i] return T | undefined instead of T. That is simply the truth — the array might be empty, the index might be off the end — and being made to acknowledge it has caught more real bugs for me than almost any single language feature. It is mildly annoying every day and occasionally saves an afternoon.
The common thread
None of these patterns are really about types. They are about when you find out you were wrong.
A type error is the cheapest bug there is. You find it before you commit, before review, before it ships, before a single user is affected. Every pattern here is a way of dragging a particular mistake earlier in time — from a three-in-the-morning page to a red build, from a support ticket to a squiggle under your cursor.
And the mistakes they catch are not random. They are disproportionately the ones that page you at three in the morning, precisely because they describe a state nobody believed could happen — which means nobody thought to test it either.
The work underneath is the same work it always was: deciding which states are real, where the boundaries sit, what must never be confused with what. TypeScript does not do that thinking for you. But once you have done it, the compiler is the most patient colleague you will ever have for holding the line. It never gets tired. It never forgets the refund case.
Use it for that.