The TypeScript Patterns That Changed How Our Team Writes Code
Discriminated unions, branded types, const assertions, and satisfies — these TypeScript patterns eliminate entire categories of bugs before your code even runs.
After shipping TypeScript across 40+ production projects, these are the patterns our team uses daily. Not academic type theory — practical patterns that catch real bugs.
1. Discriminated Unions > type Field + Casting
// ❌ The "any shape" approach
interface Notification {
type: string;
title: string;
message?: string;
amount?: number;
url?: string;
}
// ✅ Discriminated union — each variant is fully typed
type Notification =
| { type: "info"; title: string; message: string }
| { type: "payment"; title: string; amount: number; currency: string }
| { type: "link"; title: string; url: string; label: string };
function render(n: Notification) {
switch (n.type) {
case "info":
return `📝 ${n.title}: ${n.message}`;
case "payment":
return `💰 ${n.title}: ${n.amount} ${n.currency}`;
case "link":
return `🔗 ${n.title}: <a href="${n.url}">${n.label}</a>`;
// TypeScript will error if you miss a case! ✅
}
}
The compiler enforces exhaustive handling. Add a new notification type? TypeScript flags every switch statement that needs updating.
2. Branded Types — Make Invalid States Impossible
// Without branded types: easy to mix up IDs
function transferMoney(from: string, to: string, amount: number) { ... }
transferMoney(accountId, recipientId, 100); // ✅
transferMoney(recipientId, accountId, 100); // ⚠️ Swapped! No error!
// With branded types:
type AccountId = string & { readonly __brand: "AccountId" };
type RecipientId = string & { readonly __brand: "RecipientId" };
function accountId(id: string): AccountId {
return id as AccountId;
}
function transferMoney(from: AccountId, to: RecipientId, amount: number) { ... }
const myAccount = accountId("acc_123");
const recipient = recipientId("rcp_456");
transferMoney(myAccount, recipient, 100); // ✅
transferMoney(recipient, myAccount, 100); // ❌ Type error!
We use branded types for all domain IDs (UserId, OrderId, ProductId). Eliminates an entire class of parameter-ordering bugs.
3. satisfies — Type-Check Without Widening
// ❌ Type annotation widens the type
const routes: Record<string, { path: string; auth: boolean }> = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
};
routes.typo; // No error! Record<string, ...> allows any key 😱
// ✅ satisfies validates without widening
const routes = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
} satisfies Record<string, { path: string; auth: boolean }>;
routes.typo; // ❌ Error: Property 'typo' does not exist
routes.home.path; // ✅ Type is "/" (literal), not string
4. Const Assertions for Immutable Configs
// Without as const — types are widened
const PLANS = {
free: { price: 0, features: ["basic"] },
pro: { price: 29, features: ["basic", "advanced", "support"] },
};
// Type: { free: { price: number; features: string[] }; ... }
// With as const — types are narrow and readonly
const PLANS = {
free: { price: 0, features: ["basic"] },
pro: { price: 29, features: ["basic", "advanced", "support"] },
} as const;
// Type: { readonly free: { readonly price: 0; readonly features: readonly ["basic"] }; ... }
type PlanName = keyof typeof PLANS; // "free" | "pro"
type ProFeature = typeof PLANS.pro.features[number]; // "basic" | "advanced" | "support"
5. Template Literal Types for API Routes
type ApiVersion = "v1" | "v2";
type Resource = "users" | "posts" | "comments";
type ApiRoute = `/api/${ApiVersion}/${Resource}`;
// Only valid routes are allowed:
const route: ApiRoute = "/api/v1/users"; // ✅
const bad: ApiRoute = "/api/v3/users"; // ❌ Error!
const worse: ApiRoute = "/api/v1/orders"; // ❌ Error!
// Combine with mapped types for route handlers
type RouteHandlers = {
[R in ApiRoute]: (req: Request) => Response;
};
6. The infer Keyword — Extract Types from Other Types
// Extract the return type of an async function
type AsyncReturnType<T> = T extends (...args: unknown[]) => Promise<infer R>
? R
: never;
// Extract the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
// Real usage: Extract Supabase row types
type Tables = Database["public"]["Tables"];
type UserRow = Tables["users"]["Row"];
type PostInsert = Tables["posts"]["Insert"];
Our Team Rules
- Never use
any— useunknownand narrow - Never use
astype assertions — use type guards instead - Always use strict mode —
"strict": truein tsconfig - Discriminated unions for anything with variants
- Branded types for domain IDs
satisfiesover type annotations when you want autocomplete
These patterns aren't about type gymnastics — they're about making impossible states truly impossible.
Which pattern is new to you? Bookmark this and share it with your team.