Design Domain Models in TypeScript

Learn how to design domain models in TypeScript that reflect real-world concepts, enforce valid states, and keep business logic clear and type-safe.

6 min read

A TypeScript domain model is the set of types that describe what your application is about. If you are building a bookstore, your domain has books, orders, customers, and payments. The model captures not just their shapes but also the rules that make them valid.

An order must have at least one item, a payment amount must be positive, and a book ISBN must match a real pattern.

TypeScript helps here because you can encode these rules in the type system itself. A well-designed domain model makes invalid states impossible to represent at compile time.

If a function requires a confirmed order type, you cannot accidentally pass it an order that still needs payment. The compiler stops you.

Model Concepts with Branded Types

A common mistake in large codebases is using raw strings or numbers for identifiers. An order ID and a customer ID are both strings at runtime, but they are not interchangeable. Swapping them by accident is a real bug.

Branded types solve this by attaching a compile-time tag to a primitive. The tag exists only in the type system and disappears at runtime:

typescripttypescript
type Brand<T, B> = T & { __brand: B };
 
type OrderId = Brand<string, "OrderId">;
type CustomerId = Brand<string, "CustomerId">;

With these types defined, you write functions that accept only the right kind of ID. The compiler enforces this across every call site:

typescripttypescript
function getOrder(id: OrderId) { /* ... */ }
function getCustomer(id: CustomerId) { /* ... */ }
 
const orderId = "ord_123" as OrderId;
const customerId = "cus_456" as CustomerId;
 
getOrder(orderId);     // OK
getOrder(customerId);  // Compiler error

The compiler catches this at the call site and produces a clear error message. Here is the exact output you would see from the compiler when you mistakenly pass a customer ID to a function that expects an order ID:

texttext
Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'.
  Type 'CustomerId' is not assignable to type '{ __brand: "OrderId"; }'.

At runtime, both values are just strings. The brand property never exists. It is a compile-time fiction that prevents entire categories of bugs from reaching production.

Branded types work best when created at the boundary where data enters your domain. A factory function validates raw input and returns a branded value:

typescripttypescript
function createOrderId(raw: string): OrderId {
  if (!raw.startsWith("ord_")) {
    throw new Error(`Invalid order ID: ${raw}`);
  }
  return raw as OrderId;
}

After this point, every function in your domain trusts that the ID is valid. You never need to re-validate it deep inside a service.

Model States with Discriminated Unions

Real-world processes have distinct states. An order moves from pending to confirmed to shipped. Each state carries different data: a pending order has no shipping date, a shipped order does.

A discriminated union models this perfectly by using a shared literal field to tell the states apart:

typescripttypescript
type Order =
  | { status: "pending"; items: string[]; total: number }
  | { status: "confirmed"; items: string[]; total: number; confirmedAt: Date }
  | { status: "shipped"; items: string[]; total: number; shippedAt: Date; trackingId: string };

The compiler narrows the type automatically when you check the discriminant in a switch statement. Inside each case branch, only the properties that exist on that specific state are available:

typescripttypescript
function getDeliveryEstimate(order: Order): string {
  switch (order.status) {
    case "pending":
      return "Order has not been confirmed yet.";
    case "confirmed":
      return `Estimated delivery: 3 days from ${order.confirmedAt.toDateString()}`;
    case "shipped":
      return `Tracking: ${order.trackingId}`;
  }
}

Inside each case, TypeScript knows exactly which properties exist. You cannot access trackingId on a pending order because the compiler forbids it.

The switch statement also gives exhaustiveness checking. If you add a fourth state later and forget to handle it, TypeScript produces an error.

Order state transitions

Each arrow in this diagram is a function in your domain. A confirm function takes a pending order and returns a confirmed order.

A ship function takes a confirmed order and returns a shipped order. The types enforce that you never skip a step.

Build a Rich Domain Model Step by Step

Start with a plain representation of the data, then add the rules. A naive type for a book might look like this:

typescripttypescript
type Book = {
  title: string;
  isbn: string;
  price: number;
  stock: number;
};

This is too permissive. Nothing stops you from creating a book with a negative price or an invalid ISBN. The domain rules are not encoded in the types.

First, add branded types for values that carry meaning. Each one wraps a primitive with a distinct compile-time label:

typescripttypescript
type ISBN = Brand<string, "ISBN">;
type Price = Brand<number, "Price">;
type Stock = Brand<number, "Stock">;

Then write factory functions that enforce the rules at creation time. These functions are the only way to produce branded values:

typescripttypescript
function createISBN(raw: string): ISBN {
  const digits = raw.replace(/-/g, "");
  if (!/^\d{13}$/.test(digits)) {
    throw new Error(`Invalid ISBN: ${raw}`);
  }
  return raw as ISBN;
}

Each factory validates its input and either returns a branded value or throws. After a book is created, every other part of your code trusts that its fields are valid.

You never re-validate an ISBN in a service function.

Keep Domain Types Separate from API and DB Types

One of the most valuable habits in large TypeScript codebases is keeping domain types distinct from the shapes that come from your database or your API responses. These three layers change for different reasons:

LayerType purposeExample
API responseTransport shape, may include links/metadataBook title plus pagination wrapper
Database rowStorage shape, snake_case, nullableRaw column names with nullable fields
Domain modelBusiness rules, valid states onlyStrongly typed, validated values

Convert at the boundary. A repository function reads a database row and maps it to a domain type.

A controller maps a domain type to an API response. The domain never knows about the database or the transport format:

typescripttypescript
function bookFromRow(row: BookRow): Book {
  return createBook(
    row.book_title ?? "Untitled",
    row.isbn ?? "",
    row.price_in_cents / 100,
    row.stock_count
  );
}

This separation means you can change your database schema without touching business logic, and vice versa. For more on structuring a project this way, see create service layers with TypeScript.

Common Mistakes

Using string unions instead of branded types for IDs. A plain string type alias offers no protection. Brand it so the compiler catches swaps.

Putting optional fields everywhere instead of modeling states. If a field is optional on your Order type, every function that reads it must handle undefined. A discriminated union with separate types per state removes that burden.

Validating in every function instead of at the boundary. If five functions check that a price is positive, you have five places to maintain. Validate once in the factory, then trust the type.

Coupling domain types to the database. If your type has a timestamp field only because the database has it, ask whether the domain actually uses it. If not, remove it from the domain model.

For more on keeping module boundaries clean, see set package boundaries in TypeScript.

Rune AI

Rune AI

Key Insights

  • Domain models use TypeScript types to represent real-world concepts and enforce valid states.
  • Branded types prevent mixing up primitives that share the same runtime type.
  • Discriminated unions model mutually exclusive states like loading, success, and error.
  • Factory functions are the gatekeepers: they validate input and return fully-formed domain objects.
  • Keep domain types separate from API shapes and database schemas so each can evolve independently.
RunePowered by Rune AI

Frequently Asked Questions

What is a domain model in TypeScript?

A domain model is a set of TypeScript types and functions that represent the concepts, rules, and valid states of a specific business domain. It uses the type system to prevent invalid data from ever being constructed.

Should domain models use classes or plain objects?

Both work. Use plain objects with interfaces and factory functions for simpler models, especially when serialization matters. Use classes when you need encapsulation, private state, or inheritance. Many teams prefer plain objects because they serialize cleanly to JSON.

Conclusion

Domain models turn TypeScript from a type checker into a design tool. By modeling valid states with discriminated unions, preventing confusion with branded types, and exposing only intentional operations, you build a codebase where the compiler catches business logic mistakes before they reach production.