Model Objects with TypeScript Classes
Learn how to design TypeScript classes that model real-world concepts like users, orders, and products. Practical patterns for building maintainable domain models.
TypeScript class modeling means designing classes that represent real concepts: a User, an Order, a Payment, a BlogPost. A well-modeled class does more than hold data. It enforces rules, hides internals, and makes incorrect states impossible.
Compare that to a plain interface or type alias, which only describes a shape and trusts every caller to keep the data valid on their own. A class can refuse to exist in an invalid state in the first place.
This article walks through the key patterns by building up a simple e-commerce domain model piece by piece.
Start with a Value Object
A value object is a class whose identity comes from its values, not from a unique ID. Two Money objects with the same amount and currency are the same money, regardless of when or where they were created.
class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) throw new Error("Amount cannot be negative");
if (!currency) throw new Error("Currency is required");
}
add(other: Money): Money {
if (this.currency !== other.currency) throw new Error("Cannot add different currencies");
return new Money(this.amount + other.amount, this.currency);
}
}The Money class cannot be created with a negative amount or empty currency, since the constructor validates both before assigning them. The add method returns a new Money instead of modifying the existing one, keeping the value object immutable. Now create two Money instances and add them together:
const price = new Money(29, "USD");
const tax = new Money(3, "USD");
const total = price.add(tax);
console.log(total.amount, total.currency);This prints 32 USD, since add validates that both currencies match and then returns a brand new Money holding the combined amount.
32 USDValue objects are always readonly and always produce new instances for operations. They have no identity beyond their field values.
Add an Entity with Identity
An entity has a unique identity that persists even when its other properties change. A User is an entity: changing their email does not make them a different user because their ID stays the same.
class User {
public readonly id: string;
private email: string;
private name: string;
constructor(id: string, name: string, email: string) {
if (!email.includes("@")) throw new Error("Invalid email address");
this.id = id; this.name = name; this.email = email;
}
changeEmail(newEmail: string): void {
if (!newEmail.includes("@")) throw new Error("Invalid email address");
this.email = newEmail;
}
displayName(): string { return `${this.name} <${this.email}>`; }
}The id is public and readonly so external code can read it but never change it. The email and name are private, so external code can only change the email through the changeEmail method, which re-validates the new value before storing it. Now create a user and change their email:
const user = new User("usr_1", "Ada", "ada@example.com");
console.log(user.displayName());
user.changeEmail("ada.new@example.com");
console.log(user.displayName());This prints the old address, then the new one, since displayName always reads the current value of the private email field.
Ada <ada@example.com>
Ada <ada.new@example.com>Use a Static Factory Method
Sometimes the constructor is not the best way to create an object. A static factory method can handle complex creation logic, return different subclasses, or give a descriptive name to the creation process:
class Order {
public readonly id: string;
public readonly items: string[];
public readonly status: "pending" | "shipped" | "delivered";
private constructor(id: string, items: string[], status: "pending" | "shipped" | "delivered") {
this.id = id; this.items = items; this.status = status;
}
static create(items: string[]): Order { return new Order(`ord_${Date.now()}`, items, "pending"); }
ship(): Order {
if (this.status !== "pending") throw new Error("Only pending orders can be shipped");
return new Order(this.id, this.items, "shipped");
}
}The constructor is private, so the only way to create an Order is through the static create method. This guarantees every order starts in the pending state with a properly generated ID. Now create an order and ship it:
const order = Order.create(["Book", "Pen"]);
console.log(order.status);
const shipped = order.ship();
console.log(shipped.status);This prints pending, then shipped, since ship returns a brand new Order in the shipped state instead of mutating the original instance.
pending
shippedModeling Relationships Between Classes
Entities often reference other entities. An Order belongs to a User and contains OrderItems. Model these relationships with typed fields:
class OrderItem {
constructor(
public readonly productId: string,
public readonly quantity: number,
public readonly price: Money
) {
if (quantity <= 0) throw new Error("Quantity must be positive");
}
}OrderItem is its own small value object, validating that quantity is always positive. An Order can now hold a typed list of these items instead of loose primitive values:
class Order {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly items: OrderItem[]
) {}
total(): Money {
return this.items.reduce((sum, item) => sum.add(item.price), new Money(0, "USD"));
}
}The Order holds a typed list of OrderItems and a userId reference. The total method delegates to the Money class for addition, keeping currency logic in one place instead of duplicating it across every class that needs a sum.
Each class has a single responsibility and composes with others through typed references. If OrderItem's validation rules change, only OrderItem needs to change; Order and Money stay untouched because they only depend on OrderItem's public shape.
Principles for Good Class Design
These patterns share a few guiding principles that apply to any domain model:
- Validate early. Put validation in the constructor so no invalid object can exist. Throw errors for invalid input rather than returning undefined or null.
- Keep fields private by default. Expose only what consumers need. Give methods descriptive names that communicate intent rather than exposing raw setters.
- Make value objects immutable. Return new instances for operations instead of mutating existing ones. This prevents bugs where one part of the code changes an object that another part still references.
- Use the type system. Narrow types like
"pending" | "shipped" | "delivered"prevent invalid state transitions at compile time without any runtime checks.
For more on the building blocks, see classes in TypeScript explained and public private and protected in TypeScript. For design decisions between patterns, see inheritance vs composition in TypeScript.
Rune AI
Key Insights
- Model domain objects as classes when they have behavior. Use interfaces for pure data transfer.
- Value objects are immutable classes whose identity is defined by their values, not an ID.
- Validate in the constructor so no instance can exist in an invalid state.
- Use private fields to hide internal state and expose only what consumers need through methods.
- Factory methods and static constructors handle complex creation logic without exposing the raw constructor.
Frequently Asked Questions
Should I use classes or interfaces for domain models?
What is a value object in TypeScript?
Should I put validation in the constructor or in a separate method?
Conclusion
Modeling objects with TypeScript classes means thinking about what your objects are, not just what data they hold. Use private fields for encapsulation, readonly for immutability, factory methods for complex creation, and constructors that guarantee validity. A well-modeled class is impossible to create in an invalid state and communicates its intent through its type signature alone.
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.