Not another TypeScript intro. These are the patterns that survived a decade of production React and Node.js codebases: discriminated unions, template literal types, the satisfies operator, and more.
After ten years writing TypeScript across React SPAs, Node.js APIs, and shared component libraries, I've developed strong opinions about what makes a codebase maintainable versus type-safe-but-unreadable. This is not a tutorial. It is a list of the patterns I reach for consistently and why.
This is the single pattern with the highest ROI. Instead of a bag of booleans:
// ❌ The bag of booleans trap
interface DataState {
isLoading: boolean;
isError: boolean;
data: Campaign[] | null;
error: string | null;
}
// isLoading: false, isError: false, data: null, error: null — what state is this?Use a discriminated union:
// ✅ Every state is unambiguous
type CampaignState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Campaign[] }
| { status: 'error'; message: string };Now components exhaustively narrow state:
function CampaignList({ state }: { state: CampaignState }) {
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <ErrorMessage text={state.message} />;
if (state.status === 'idle') return null;
// TypeScript knows state.data: Campaign[] here
return <ul>{state.data.map(c => <li key={c.id}>{c.name}</li>)}</ul>;
}I reach for this any time a component has more than one meaningful loading/error/success combination.
satisfies Operator (TypeScript 4.9+)Before satisfies, you had a choice: annotate and lose inference, or don't annotate and lose validation.
const routes = {
home: '/',
about: '/about',
campaigns: '/campaigns',
} as const;
// Fine, but no validation that values are stringsWith satisfies:
const routes = {
home: '/',
about: '/about',
campaigns: '/campaigns',
} satisfies Record<string, `/${string}`>;TypeScript validates the shape AND you keep literal type inference. routes.home is still '/', not string. I use this extensively for config objects, icon maps, and route definitions.
This has eliminated a whole class of typo bugs in our routing layer:
type Locale = 'en' | 'de';
type PageSlug = 'home' | 'about' | 'blog' | 'contact';
type LocalizedRoute = `/${Locale}/${PageSlug}`;
// '/en/home' | '/en/about' | '/de/blog' | ... (8 valid combinations)
function navigate(route: LocalizedRoute) {
window.location.href = route;
}
navigate('/en/home'); // ✅
navigate('/fr/home'); // ❌ Type error at compile time
navigate('/en/pricing'); // ❌ Type error at compile timeReal-world application: we use this to type our Contentful entry IDs, API route params, and i18n message keys.
try/catch scattered across async functions produces inconsistent error handling. A typed Result type eliminates this:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function safeAsync<T>(
fn: () => Promise<T>
): Promise<Result<T>> {
try {
return { ok: true, value: await fn() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}Usage:
const result = await safeAsync(() => fetchLeads(userId));
if (!result.ok) {
logger.error('Failed to fetch leads', result.error);
return;
}
// result.value is typed as Lead[] here
processLeads(result.value);This pattern makes error paths explicit and forces the caller to handle failures instead of letting them bubble silently.
any in React Event HandlersThis is a small thing that comes up constantly in code reviews:
// ❌ What we see in PRs
const handleChange = (e: any) => setValue(e.target.value);
// ✅ Fully typed, zero magic
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
// ✅ Or for form submit
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ...
};React exports all the event types. There is almost never a legitimate reason for any in an event handler.
I stopped using TypeScript enum years ago. Const assertions give you the same safety without the runtime object or the emit quirks:
const CAMPAIGN_STATUS = ['draft', 'active', 'paused', 'archived'] as const;
type CampaignStatus = typeof CAMPAIGN_STATUS[number];
// type CampaignStatus = 'draft' | 'active' | 'paused' | 'archived'
function isValidStatus(value: string): value is CampaignStatus {
return (CAMPAIGN_STATUS as readonly string[]).includes(value);
}You get a runtime array (useful for rendering status dropdowns), a compile-time union type, and a type guard, all from one declaration.
When your codebase has userId: string, campaignId: string, and leadId: string, a function that accidentally receives the wrong one won't be caught by TypeScript. Branded types fix this at zero runtime cost:
type Brand<T, Brand> = T & { readonly __brand: Brand };
type UserId = Brand<string, 'UserId'>;
type CampaignId = Brand<string, 'CampaignId'>;
function getLeads(userId: UserId): Promise<Lead[]> { /* ... */ }
const campaignId = '123' as CampaignId;
getLeads(campaignId); // ❌ Type error — CampaignId is not UserIdI add this wherever ID confusion has caused a production bug or a confusing debugging session.
The theme across all of these: make illegal states unrepresentable. TypeScript's type system is expressive enough to encode business rules at compile time. Every runtime check you can eliminate with a type is one fewer place for bugs to hide.
Start with discriminated unions in your state management. Add satisfies to your config objects. Replace try/catch ladders with a typed Result. The codebase will thank you in six months.