After 9 years writing TypeScript across enterprise codebases, these are the patterns that actually reduce bugs and make code easier to reason about at scale. Not the basics — the things I wish someone had shown me earlier.
Discriminated unions for state
This is the biggest single TypeScript upgrade you can apply to how you model state:
// ❌ The naive way — requires null checks everywhere
type RequestState = {
status: "idle" | "loading" | "success" | "error";
data: User | null;
error: string | null;
};
// ✅ Discriminated union — each state is airtight
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
With the discriminated union, TypeScript narrows the type in switch/if blocks. You can't access data without first checking status === "success". Bugs that used to be runtime errors become compile-time errors.
Branded types for IDs
In large systems you have many different entity IDs — userId, workspaceId, conversationId. They're all strings. TypeScript won't stop you from passing a userId where a workspaceId is expected.
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type WorkspaceId = Brand<string, "WorkspaceId">;
// TypeScript will error if you mix them up:
function getConversations(workspaceId: WorkspaceId) { ... }
const userId = "abc" as UserId;
getConversations(userId); // ❌ Type error — caught at compile time
In a codebase with hundreds of functions passing IDs around, this catches a class of bugs that are painful to debug in production.
const assertions for configuration
// Without const assertion — type is string[]
const ROLES = ["owner", "admin", "agent", "viewer"];
// With const assertion — type is readonly ["owner", "admin", "agent", "viewer"]
const ROLES = ["owner", "admin", "agent", "viewer"] as const;
type Role = typeof ROLES[number]; // "owner" | "admin" | "agent" | "viewer"
This pattern is useful any time you have a list of string constants. You get a union type automatically, and you get completeness checking in switch statements.
satisfies for config objects
New in TypeScript 4.9 and underused:
type RouteConfig = Record<string, { path: string; auth: boolean }>;
// satisfies checks that the object matches the type
// but preserves the literal types of each value
const ROUTES = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
} satisfies RouteConfig;
// ROUTES.home.path is inferred as "/" (not string)
// Good for configs where you want type safety AND literal inference
Utility types I actually use
Pick and Omit for deriving DTO types from entity types. Partial for update payloads. Required for ensuring all optional fields are present after validation. ReturnType and Awaited for inferring types from functions without repeating yourself.
The pattern: define your canonical types once (usually from your database schema or API contracts), then derive everything else using utility types. Changing a field in one place propagates everywhere.
The rule that ties it together
Make illegal states unrepresentable. If your type allows a state that your business logic doesn't allow, you're relying on runtime checks and discipline instead of the compiler. TypeScript is good at preventing you from expressing invalid states — if you let it.