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.
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:
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:
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 errorThe 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:
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:
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:
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:
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.
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:
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:
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:
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:
| Layer | Type purpose | Example |
|---|---|---|
| API response | Transport shape, may include links/metadata | Book title plus pagination wrapper |
| Database row | Storage shape, snake_case, nullable | Raw column names with nullable fields |
| Domain model | Business rules, valid states only | Strongly 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:
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
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.
Frequently Asked Questions
What is a domain model in TypeScript?
Should domain models use classes or plain objects?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.