TypeScript Patterns I Actually Use Every Day
Battle-tested TypeScript patterns for real-world applications — discriminated unions, branded types, exhaustive checks, and more.
I see a lot of incredibly complex TypeScript gymnastics on Twitter, but 99% of it never makes it into my production codebases. After writing TS full-time for years, here are the actual patterns I reach for constantly.
Stop Using Booleans for State
Modeling state with isLoading and isError booleans is a trap. I prefer discriminated unions because they force the compiler to act as a state machine enforcer.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
If you try to access data before checking that status === 'success', the compiler yells at you. It completely eliminates the need for all those defensive if (data) checks scattered throughout your UI.
Branded Types for Safety
A string that holds a user ID is fundamentally different from a string holding an email address, but TypeScript doesn't care. Branded types fix this without adding any runtime overhead.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
const createUserId = (id: string): UserId => id as UserId;
const createEmail = (email: string): Email => email as Email;
Now if you accidentally pass an email string into a function expecting a user ID, the build fails. I use this constantly for database primary keys.
The satisfies Operator is Underrated
I use satisfies everywhere now. It validates that an object matches a shape, but it doesn't erase the literal types of the keys and values like a standard type annotation does.
const ROUTES = {
home: '/',
blog: '/blog',
contact: '/contact',
} satisfies Record<string, string>;
// Type is still { home: "/"; blog: "/blog"; contact: "/contact" }
// NOT Record<string, string>
It is perfect for defining theme tokens, route configs, or anything where you want autocomplete on the exact values later on.
Exhaustive Switch Statements
When dealing with those discriminated unions, you want to guarantee that you haven't forgotten a state. I always throw an exhaustive check at the bottom.
const assertNever = (value: never): never => {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
};
const getLabel = (status: AsyncState<unknown>['status']): string => {
switch (status) {
case 'idle':
return 'Ready';
case 'loading':
return 'Loading...';
case 'success':
return 'Done';
case 'error':
return 'Failed';
default:
return assertNever(status); // TS complains here if you missed a case!
}
};
If a backend engineer adds a new status to the API type, the frontend build instantly breaks exactly where I need to handle it.
The One I Still Struggle With
I'll be honest, the infer keyword inside conditional types still breaks my brain sometimes. I can read it, but every time I have to write a complex generic using it, I end up staring at my monitor for 20 minutes. But that's part of the fun.
