Dataslope logoDataslope

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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

Now the compiler enforces that:

  • An "active" user always has a role (member or admin).
  • A "suspended" user always has a reason.
  • 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:

Code Block
TypeScript 5.7

Now each state carries exactly the data it needs:

  • "draft": No extra fields.
  • "submitted": Has a shippingAddress (required to submit).
  • "paid": Has both paymentId and shippingAddress.
  • "shipped": Has paymentId, shippingAddress, and trackingNumber.
  • "cancelled": Has a reason.

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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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.

Challenge
TypeScript 5.7
Refining a BlogPost model

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:

  1. In post.ts, define a PostState discriminated union with three variants:
  • "draft": no extra fields
  • "published": requires slug: string and publishedAt: number
  • "archived": requires archivedAt: number
  1. Define a Post type with id, title, and state: PostState.
  2. In main.ts, implement describePost to return a formatted string for each state.
  3. The tests will verify all three states render correctly.

Multiple choice check

QuestionSelect one

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:

  1. Make illegal states unrepresentable. If two fields are mutually exclusive, don't use booleans or optionals — use a discriminated union.
  2. Use literal types for constrained values. If a field can only be "low" | "medium" | "high", don't type it as string.
  3. Encode business rules in types. If a shipped order must have a tracking number, put trackingNumber in the "shipped" variant, not as an optional field on the base type.
  4. Avoid boolean soup. Multiple booleans (isX, isY, isZ) are usually a sign you need a discriminated union.
  5. 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 than string.
  • Exhaustiveness checking: Add const _exhaustive: never = x in 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.

On this page