Domain modeling in TypeScript at scale
Using discriminated unions and nominal typing so invalid states are unrepresentable—and refactors stay mechanical.
Enterprise TypeScript stays maintainable when domain rules live in types, not tribal knowledge. Two patterns I standardize on are discriminated unions for lifecycle states and branded primitives for identifiers.
Discriminated unions encode workflows
When an entity’s legal fields depend on its phase, a wide interface with optional properties forces defensive checks everywhere. A union ties each status to the fields that may exist:
type OrderPending = {
readonly status: "pending";
readonly createdAt: string;
};
type OrderPaid = {
readonly status: "paid";
readonly createdAt: string;
readonly paidAt: string;
readonly paymentIntentId: string;
};
type Order = OrderPending | OrderPaid;
function shipLabel(order: Order): string | null {
if (order.status !== "paid") return null;
return `label:${order.paymentIntentId}`;
}
Exhaustive switch on status satisfies the compiler and documents the contract for reviewers.
Branded types for opaque identifiers
Primitive obsession (string everywhere) allows mixing userId, tenantId, and raw tokens. Lightweight branding preserves ergonomics while preventing accidental substitution:
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type TenantId = Brand<string, "TenantId">;
function assertUserId(id: string): UserId {
return id as UserId;
}
Boundary parsing
External payloads should be validated at the edge (e.g. with a schema library), then mapped into these internal types. That keeps domain types stable when vendor JSON drifts.
Takeaway: Invest in the type system up front; the return is faster refactors, safer APIs, and less time spent in runtime guards.