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.

8 min read

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.

typescripttypescript
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:

typescripttypescript
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.

texttext
32 USD

Value 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.

typescripttypescript
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:

typescripttypescript
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.

texttext
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:

typescripttypescript
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:

typescripttypescript
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.

texttext
pending
shipped

Modeling Relationships Between Classes

Entities often reference other entities. An Order belongs to a User and contains OrderItems. Model these relationships with typed fields:

typescripttypescript
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:

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Should I use classes or interfaces for domain models?

Use classes when the model has behavior (methods) or needs encapsulation (private fields). Use interfaces or type aliases when the model is pure data with no logic. Many TypeScript projects use interfaces for API response types and classes for domain objects with behavior.

What is a value object in TypeScript?

A value object is a class whose identity comes from its values rather than a unique ID. Two value objects are equal if all their properties match. Value objects are typically immutable (all fields readonly) and have no lifecycle beyond creation.

Should I put validation in the constructor or in a separate method?

Put validation in the constructor when the object cannot exist in an invalid state. This guarantees that every instance of the class is valid by construction. Use a separate validation method only when you need to validate partial or incomplete objects.

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.