Programming · TypeScript
TypeScript Patterns That Make Large Codebases Calmer
Five patterns we reach for when a TypeScript codebase grows past the point where everyone can hold it in their head.
· 2 min read · By george
TypeScript pays off most when a codebase is too big for any one person to remember. These are the patterns we come back to on client projects, because each one turns a class of runtime bug into a compile error.
1. Discriminated unions for state
Instead of a bag of optional fields, model each state explicitly:
type Request<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
Now data only exists when status === "success", and the compiler enforces it. No more data! or "how can this be undefined here?".
2. Exhaustive checks with never
Pair unions with a switch that fails to compile when someone adds a new case and forgets to handle it:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
switch (request.status) {
case "idle": return null;
case "loading": return <Spinner />;
case "success": return <View data={request.data} />;
case "error": return <ErrorBanner message={request.error} />;
default: return assertNever(request);
}
3. Branded types for IDs
A userId and an orderId are both strings, so nothing stops you passing one where the other belongs. A brand makes them distinct at zero runtime cost:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function getOrder(id: OrderId): Promise<Order>;
getOrder(userId); // ✗ compile error
4. Validate at the edges, trust inside
Types disappear at runtime, so data from an API, a form or JSON.parse is really unknown. Parse it once at the boundary with a schema library such as Zod, and derive the type from the schema so the two can't drift:
const User = z.object({ id: z.string(), email: z.string().email() });
type User = z.infer<typeof User>;
const user = User.parse(await res.json()); // throws on bad data
Inside the boundary, the rest of the code can trust User completely.
5. satisfies for configuration objects
satisfies checks a value against a type without widening it, so you keep the precise literal types:
const routes = {
home: "/",
blog: "/blog",
} satisfies Record<string, `/${string}`>;
routes.blog; // type is "/blog", not string
The common thread
Each pattern moves knowledge out of people's heads and into the type system. That's what makes a large codebase calm: you can change something on one side of it and let the compiler tell you everything else that has to change.
Turn on strict (and ideally noUncheckedIndexedAccess), and these patterns get even more mileage.
Comments
Loading comments…