TL;DR: Most TypeScript codebases use any as a crutch and string literals as identifiers. These 7 patterns — discriminated unions, exhaustive switches, branded types, satisfies, const assertions, template literal types, and result types — move error detection from runtime to compile time. Each one is a real refactor I’ve applied in production.
TypeScript gives you a type system. Whether you actually use it is another question.
Most codebases I’ve worked in treat TypeScript like “JavaScript with autocomplete.” They sprinkle interface on API responses, use any when things get awkward, and call it typed. The result is the worst of both worlds — you pay the TypeScript tax (syntax, build step, config) without getting the real payoff: compile-time safety.
These 7 patterns fix that. Each one is a concrete refactor you can apply today.
Table of Contents
- 1. Discriminated Unions for State Machines
- 2. Exhaustive Switch with
never - 3. Branded Types for Semantic Safety
- 4.
satisfiesfor Type-Checked Literals - 5. Const Assertions for Immutable Data
- 6. Template Literal Types for String Validation
- 7. Result Types Instead of Thrown Errors
- When to Apply These
1. Discriminated Unions for State Machines
The most common bug pattern in frontend code: a component that has multiple states but represents them with a bag of optional properties.
// ❌ The "bag of optionals" anti-pattern
interface ApiState {
loading: boolean;
data?: User[];
error?: string;
}
// Nothing stops you from setting loading: true AND error: "fail"
Fix it with a discriminated union:
// ✅ Each state is explicit and mutually exclusive
type ApiState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
function renderUsers(state: ApiState) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return state.data.map(renderUser); // TS knows data exists
case "error":
return <ErrorBanner message={state.error} />; // TS knows error exists
}
}
Now it’s impossible to access data in the error state. The compiler enforces the state machine.
2. Exhaustive Switch with never
Discriminated unions are only half the story. You need the compiler to scream when you add a new variant and forget to handle it.
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
type PaymentMethod = 'card' | 'bank' | 'crypto';
function getIcon(method: PaymentMethod): string {
switch (method) {
case 'card':
return '💳';
case 'bank':
return '🏦';
case 'crypto':
return '₿';
default:
return assertNever(method);
// If someone adds "paypal" to the union,
// this line throws a compile error — not a runtime bug
}
}
This pattern turns “forgot to handle the new case” from a production bug into a red squiggly in your editor.
3. Branded Types for Semantic Safety
TypeScript’s structural typing means string is string. A user ID and an order ID are interchangeable. That’s a bug waiting to happen.
// Create distinct types that are structurally incompatible
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function getOrder(orderId: OrderId) {
/* ... */
}
const userId = createUserId('usr_123');
getOrder(userId); // ❌ Compile error! Can't pass UserId as OrderId
Zero runtime cost. The brand exists only in the type system and is erased at compile time. Use this for any ID, token, or value where mixing types would be a silent, devastating bug.
4. satisfies for Type-Checked Literals
Before satisfies, you had to choose: either widen the type with a type annotation (losing literal inference), or skip the annotation (losing validation).
type Route = {
path: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
};
// ❌ Type annotation: works but widens literals
const routes: Record<string, Route> = {
users: { path: '/users', method: 'GET' },
};
// routes.users.method is "GET" | "POST" | "PUT" | "DELETE" — not "GET"
// ✅ satisfies: validates AND preserves literal type
const routes = {
users: { path: '/users', method: 'GET' },
createUser: { path: '/users', method: 'POST' },
} satisfies Record<string, Route>;
// routes.users.method is "GET" — the exact literal
Use satisfies anywhere you want the compiler to validate a shape while preserving the specific inferred type.
5. Const Assertions for Immutable Data
as const locks down an object or array to its most specific type and makes it deeply readonly.
// Without as const — types are widened
const config = { theme: 'dark', maxRetries: 3 };
// config.theme is string, config.maxRetries is number
// With as const — types are narrowed and frozen
const config = { theme: 'dark', maxRetries: 3 } as const;
// config.theme is "dark", config.maxRetries is 3
// config is Readonly — mutations are compile errors
This is essential for configuration objects, lookup tables, and any value that shouldn’t be mutated after declaration.
6. Template Literal Types for String Validation
TypeScript can validate string patterns at the type level.
type HexColor = `#${string}`;
type EventName = `on${Capitalize<string>}`;
type ApiEndpoint = `/api/${string}`;
function setColor(color: HexColor) {
/* ... */
}
setColor('#ff0000'); // ✅
setColor('red'); // ❌ Compile error
function registerEvent(name: EventName, handler: () => void) {
/* ... */
}
registerEvent('onClick', handleClick); // ✅
registerEvent('click', handleClick); // ❌ Compile error
Combine with mapped types for powerful API contracts:
type CrudEndpoints<T extends string> = {
[K in `${'get' | 'create' | 'update' | 'delete'}${Capitalize<T>}`]: () => Promise<void>;
};
// Generates: getUser, createUser, updateUser, deleteUser
type UserApi = CrudEndpoints<'user'>;
7. Result Types Instead of Thrown Errors
Thrown exceptions break the type system. The function signature says nothing about what can go wrong.
// ❌ Caller has no idea this can throw
function parseConfig(raw: string): Config {
const parsed = JSON.parse(raw); // throws on invalid JSON
if (!parsed.version) throw new Error('Missing version');
return parsed as Config;
}
// ✅ Error is part of the return type
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseConfig(raw: string): Result<Config, 'invalid_json' | 'missing_version'> {
try {
const parsed = JSON.parse(raw);
if (!parsed.version) return { ok: false, error: 'missing_version' };
return { ok: true, value: parsed as Config };
} catch {
return { ok: false, error: 'invalid_json' };
}
}
// Caller is forced to handle both cases
const result = parseConfig(input);
if (!result.ok) {
// result.error is "invalid_json" | "missing_version" — fully typed
console.error(result.error);
return;
}
// result.value is Config — safe to use
This makes failure an explicit part of your API contract. No more try/catch gambling.
When to Apply These
Don’t refactor your entire codebase at once. Apply each pattern at the point of pain:
- Getting weird
undefinedcrashes? → Discriminated unions (#1) + exhaustive checks (#2) - Mixing up IDs or tokens? → Branded types (#3)
- Config objects losing their literal types? →
satisfies(#4) + const assertions (#5) - Accepting malformed strings? → Template literal types (#6)
- Try/catch scattered everywhere? → Result types (#7)
Each pattern is zero runtime cost. They exist purely in the type system and are erased when TypeScript compiles to JavaScript. You get the safety for free.