Modeling Domains with Types
Make illegal states unrepresentable with precise type design
The difference between mediocre TypeScript and great TypeScript often comes down to how you model your domain. A well-designed type system doesn't just catch typos — it makes entire classes of bugs impossible. This is the idea behind making illegal states unrepresentable: if a combination of values doesn't make sense in your domain, the type system shouldn't allow it to exist.
This chapter is the capstone of type-driven design. We'll take sloppy, bug-prone type definitions and evolve them into precise, self-documenting structures that use discriminated unions, literal types, and careful narrowing to eliminate invalid states. By the end, you'll see how the compiler can enforce your business logic at compile time, long before any code runs.
The problem: boolean soup
Let's start with a common anti-pattern. Suppose you're modeling a user in a multi-tenant application:
// ❌ Bad: allows nonsensical combinations
type User = {
id: string;
name: string;
isActive: boolean;
isAdmin: boolean;
isSuspended: boolean;
suspensionReason: string | null;
};At first glance, this looks fine. But think about the combinations it allows:
isActive: true, isSuspended: true— Is the user active or suspended? Both?isSuspended: false, suspensionReason: "Violated terms"— The user isn't suspended, but there's a suspension reason?isAdmin: true, isActive: false— An inactive admin? Does that mean they still have admin privileges?
These are illegal states: combinations that don't make sense in your domain. The type system isn't helping you — it's allowing garbage in, and you'll have to write defensive code everywhere to check for these contradictions.
The function canAccessAdmin has to guard against impossible states. This is a sign your types aren't pulling their weight.
The solution: discriminated unions
The fix is to model the user's status as a discriminated union. A user can be in exactly one state at a time: active, suspended, or inactive. Each state carries its own data.
Now the compiler enforces that:
- An
"active"user always has arole(member or admin). - A
"suspended"user always has areason. - An
"inactive"user has neither — just the status.
You can't create a user who is both active and suspended. You can't forget the suspension reason. The type system has encoded your business logic.
Encode invariants in types
Whenever you find yourself writing comments like "if isSuspended is true, suspensionReason must not be null," that's a sign you should encode the invariant in the type system instead. Use a discriminated union to make the valid combinations explicit and the invalid ones impossible.
Evolving a sloppy type: the Order example
Let's walk through a more complex example: an e-commerce order. The naive version uses optional fields:
// ❌ Bad: optional fields create ambiguity
type SloppyOrder = {
id: string;
items: Array<{ productId: string; quantity: number }>;
status: "draft" | "submitted" | "paid" | "shipped" | "cancelled";
paymentId?: string;
shippingAddress?: string;
trackingNumber?: string;
cancellationReason?: string;
};This allows nonsense like:
status: "draft", paymentId: "pay123"— A draft with a payment ID?status: "shipped", trackingNumber: undefined— Shipped but no tracking?status: "paid", cancellationReason: "Out of stock"— Paid and cancelled?
The type is too loose. Let's tighten it with a discriminated union:
Now each state carries exactly the data it needs:
"draft": No extra fields."submitted": Has ashippingAddress(required to submit)."paid": Has bothpaymentIdandshippingAddress."shipped": HaspaymentId,shippingAddress, andtrackingNumber."cancelled": Has areason.
You can't forget the tracking number when marking an order as shipped. You can't have a draft with a payment ID. The compiler enforces the business logic.
Redundancy is fine
Notice that shippingAddress appears in multiple variants. That's okay! Each variant is self-contained. The alternative — trying to share fields with inheritance or intersection types — often leads to more complexity than it's worth.
Replacing booleans with variants
Booleans are often a code smell. If you see a boolean flag that changes the meaning of other fields, consider replacing it with a discriminated union.
Before:
type PaymentMethod = {
isCreditCard: boolean;
cardNumber?: string; // only if isCreditCard is true
bankAccount?: string; // only if isCreditCard is false
};After:
type PaymentMethod =
| { type: "credit-card"; cardNumber: string }
| { type: "bank-account"; accountNumber: string };The type discriminant replaces the boolean, and now the compiler knows which field to expect.
Each payment method is a distinct variant with its own fields. No optional properties, no runtime checks for "is this a credit card or a bank account?" — the type tells you.
Literal types for constrained values
Sometimes a field can only take a few valid values. Use literal types instead of unconstrained strings or numbers.
If you used priority: string, the compiler wouldn't catch typos like "urgent!" or "URGENT". The literal union restricts the value to the valid set, and the compiler verifies exhaustiveness when you map over them.
Challenge: Refining a weak model
Now it's your turn. You'll take a sloppy type and refine it into a discriminated union that makes illegal states unrepresentable.
You're modeling blog posts. The current BadPost type uses optional fields, allowing nonsense like a draft with a published date or a published post with no slug.
Your tasks:
- In
post.ts, define aPostStatediscriminated union with three variants:
"draft": no extra fields"published": requiresslug: stringandpublishedAt: number"archived": requiresarchivedAt: number
- Define a
Posttype withid,title, andstate: PostState. - In
main.ts, implementdescribePostto return a formatted string for each state. - The tests will verify all three states render correctly.
Multiple choice check
You're modeling a feature flag that can be:
Disabled
Enabled for everyone
Enabled for a specific percentage of users
Principles of domain modeling
As you design types for your application, keep these principles in mind:
- Make illegal states unrepresentable. If two fields are mutually exclusive, don't use booleans or optionals — use a discriminated union.
- Use literal types for constrained values. If a field can only be
"low" | "medium" | "high", don't type it asstring. - Encode business rules in types. If a shipped order must have a tracking number, put
trackingNumberin the"shipped"variant, not as an optional field on the base type. - Avoid boolean soup. Multiple booleans (
isX,isY,isZ) are usually a sign you need a discriminated union. - Prefer specificity over flexibility. It's better to have a type that's "too narrow" and refine it later than to have a type that's "too loose" and allows garbage.
Balance precision and pragmatism
Don't go overboard. If a field genuinely is optional in all contexts (like a user's middle name), use middleName?: string. The goal isn't to eliminate every ? or | null — it's to eliminate invalid combinations of fields that represent logically impossible states.
Recap
Type-driven design is about encoding your domain's invariants in the type system so the compiler can enforce them. When you model states, events, commands, or any other variant data, use discriminated unions to make each case explicit. When you have constrained values, use literal types to restrict the valid set. When you see optional fields that only make sense in certain states, factor them into state-specific variants.
The payoff is massive: fewer runtime checks, better autocomplete, clearer code, and entire categories of bugs that become compile-time errors. When you add a new variant, every switch that handles the union lights up with an error until you handle the new case. The compiler becomes your refactoring partner.
Key takeaways:
- Make illegal states unrepresentable. Use discriminated unions to model mutually exclusive states.
- Replace booleans with variants. If a boolean changes the meaning of other fields, use a discriminant instead.
- Use literal types for constrained values.
"low" | "medium" | "high"is better thanstring. - Exhaustiveness checking: Add
const _exhaustive: never = xin the default case to catch missing variants.
Next up: Generics Basics, where we explore how to write reusable, type-safe code that works with any type — without losing type information to any.