Value Objects in Practice

Money, Email, DateRange, Address. How I model value objects in TypeScript, why immutability and structural equality matter, and how to persist them without leaking infra into the domain.

A Wednesday afternoon, second hour of a code review at the digital product agency I led at in London. I was looking at a pull request where a junior engineer had added a discountPercent: number to an Order entity. The diff was small. The test passed. I left a comment that turned into a thirty-minute conversation. “What’s 110?” I asked. “A bug,” he said. Yeah. That was the whole point. Numbers don’t know they’re percents. Strings don’t know they’re emails. Pairs of dates don’t know they should be ordered. Value objects do.

I’m going to walk through four value objects I’ve shipped to production across a few different products: Money, Email, DateRange, and Address. Different shapes, same rules. The rules are boring on purpose. Immutability, structural equality, validation in the constructor, no setters, no leaking into the database layer. If the value object doesn’t enforce its own correctness, the entity that holds it has to, and the entity already has enough to do.

Money is not a number

Money is the value object that catches people the most. Folks reach for number, then they hit floating-point rounding, then they reach for string, then they hit “how do I add two strings”. I keep it as an integer amount in the currency’s minor unit plus a currency code. Add only adds same currencies. Multiply by a scalar is fine. There’s no setter, there’s no convertTo(otherCurrency) because exchange rates don’t belong in the domain.

// domain/shared/money.ts
export class Money {
  private constructor(
    public readonly amount: number, // minor units, e.g. cents
    public readonly currency: string,
  ) {}

  static of(amount: number, currency: string): Money {
    if (!Number.isInteger(amount)) {
      throw new Error(`Money.amount must be an integer in minor units, got ${amount}`);
    }
    if (!/^[A-Z]{3}$/.test(currency)) {
      throw new Error(`Money.currency must be ISO 4217, got "${currency}"`);
    }
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (other.currency !== this.currency) {
      throw new Error(`cannot add ${this.currency} to ${other.currency}`);
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  times(scalar: number): Money {
    return new Money(Math.round(this.amount * scalar), this.currency);
  }

  equals(other: Money): boolean {
    return other instanceof Money
      && other.amount === this.amount
      && other.currency === this.currency;
  }
}

A few things to notice. The constructor is private, the factory is of. That way you can’t slip past validation. add refuses to mix currencies at runtime. equals is structural, not referential. times rounds at the end, not partway through, because half-cent intermediate values are how customers end up paying the wrong amount.

War story for this one. At the real-time trading platform I architected, we had a price aggregation path that multiplied tick prices by lot sizes inside a hot loop. Someone refactored a helper and casually changed an integer arithmetic step into a float division. CI passed. Pricing on the chart drifted by a few minor units per quote. Across roughly 10M concurrent market-data streams, that turned into visible chart wobble during a busy open. We didn’t catch it for about an hour. The fix was a Money type and a test that pinned arithmetic to integer minor units. Boring. Worth it.

Email looks easy and is not

Email is the value object that looks like a thin wrapper around string until you trace where it gets passed around. Normalize on the way in. Compare normalized.

// domain/shared/email.ts
export class Email {
  private constructor(public readonly value: string) {}

  static of(raw: string): Email {
    const trimmed = raw.trim().toLowerCase();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      throw new Error(`invalid email: ${raw}`);
    }
    return new Email(trimmed);
  }

  domain(): string {
    return this.value.split("@")[1];
  }

  equals(other: Email): boolean {
    return other instanceof Email && other.value === this.value;
  }
}

The regex is intentionally weak. RFC-correct email regex is a meme. What matters is that you normalize case and trim whitespace once, at the boundary, and then never have to think about it again. I’ve seen production bugs where User.findBy(email: "[email protected]") and User.findBy(email: "[email protected]") were two different accounts. Both records existed. Both had partial subscription state. That’s not a routing bug, that’s a missing value object.

DateRange enforces ordering

A DateRange value object is where the “validate in the constructor” rule pays for itself. If you let entities hold from: Date and to: Date independently, every place that uses them has to remember to check from <= to. Half of them won’t.

// domain/shared/date-range.ts
export class DateRange {
  private constructor(
    public readonly from: Date,
    public readonly to: Date,
  ) {}

  static of(from: Date, to: Date): DateRange {
    if (from.getTime() > to.getTime()) {
      throw new Error(`DateRange: from (${from.toISOString()}) is after to (${to.toISOString()})`);
    }
    return new DateRange(new Date(from), new Date(to));
  }

  overlaps(other: DateRange): boolean {
    return this.from <= other.to && other.from <= this.to;
  }

  durationMs(): number {
    return this.to.getTime() - this.from.getTime();
  }
}

Notice the defensive copy in the factory. Date is mutable in JavaScript. If you store the reference your caller passes in and they mutate it later, you’ve got a value object that isn’t a value object anymore. Copy on the way in. Treat the inside of the object as frozen even though TypeScript can’t always prove it.

Address is a tiny aggregate

Address is bigger and the temptation is to model it as an entity with its own id. I’d push back. It has no identity of its own. Two addresses with the same fields are the same address. Identity belongs to whoever owns the address, the Customer or the Shipment.

// domain/shared/address.ts
export class Address {
  private constructor(
    public readonly line1: string,
    public readonly line2: string | null,
    public readonly city: string,
    public readonly postalCode: string,
    public readonly country: string, // ISO 3166-1 alpha-2
  ) {}

  static of(input: {
    line1: string;
    line2?: string | null;
    city: string;
    postalCode: string;
    country: string;
  }): Address {
    if (!/^[A-Z]{2}$/.test(input.country)) {
      throw new Error(`country must be ISO 3166-1 alpha-2, got "${input.country}"`);
    }
    if (!input.line1.trim() || !input.city.trim() || !input.postalCode.trim()) {
      throw new Error(`address missing required field`);
    }
    return new Address(
      input.line1.trim(),
      input.line2?.trim() || null,
      input.city.trim(),
      input.postalCode.trim().toUpperCase(),
      input.country,
    );
  }

  equals(other: Address): boolean {
    return other instanceof Address
      && other.line1 === this.line1
      && (other.line2 ?? "") === (this.line2 ?? "")
      && other.city === this.city
      && other.postalCode === this.postalCode
      && other.country === this.country;
  }
}

Persisting value objects without leaking

Where things get interesting is the boundary with the database. There are two ways I’ve shipped this. JSON columns when the value object is read whole and never queried by its parts. Custom column types or composite columns when you need to query.

For Money against PostgreSQL via TypeORM, a custom transformer keeps the domain shape clean.

// infra/shared/money.column.ts
import { ValueTransformer } from "typeorm";
import { Money } from "../../domain/shared/money";

export const moneyTransformer: ValueTransformer = {
  to: (m: Money | null) => (m ? { amount: m.amount, currency: m.currency } : null),
  from: (raw: { amount: number; currency: string } | null) =>
    raw ? Money.of(raw.amount, raw.currency) : null,
};

// usage:
// @Column({ type: "jsonb", transformer: moneyTransformer })
// total!: Money;

Address I usually flatten into columns on the parent table. Five columns, one row. Easy to index country for shipping rules, easy to join, no JSON cleverness. The mapper between row and value object lives in infra, never in the domain.

// infra/customers/address.mapper.ts
import { Address } from "../../domain/shared/address";

export function addressFromRow(row: {
  address_line1: string;
  address_line2: string | null;
  address_city: string;
  address_postal_code: string;
  address_country: string;
}): Address {
  return Address.of({
    line1: row.address_line1,
    line2: row.address_line2,
    city: row.address_city,
    postalCode: row.address_postal_code,
    country: row.address_country,
  });
}

Second war story. At the creator economy platform I worked at, a renewal-notification handler accepted Apple’s server-to-server payload and wrote a row per call. Apple retries when your endpoint responds slowly. Our endpoint occasionally did. So renewal events arrived twice for the same transaction. Without a value object representing “this is the same renewal”, every retry created a fresh subscription row. A few thousand customers were billed twice in a single billing window. The frontend fix that went out within an hour hid the duplicate row in the UI. Did nothing for the duplicate charge. The structural fix was a value object derived from (apple_original_transaction_id, notification_uuid), a unique database constraint on those two columns, and a Sidekiq job so the endpoint could 200 OK in under five seconds. Apple’s retries became idempotent at the queue level. Idempotency keys are value objects too. They’re just a value object you didn’t know you needed until you got billed for forgetting.

When I’d skip the ceremony

Honestly, not every primitive needs a value object. A free-text product description? Plain string. A boolean flag? Plain boolean. The cost of a value object is the wrapper. The benefit is the validation and the type-level proof that you can’t accidentally pass a “name” where a “city” goes. Use them where wrong values cause silent damage. Skip them where wrong values are loud and cheap.

Takeaways

  • Immutability is non-negotiable. Private constructor, factory method, no setters. Defensive-copy mutable inputs.
  • Validate in the factory. If construction fails, the value object never exists.
  • Structural equality, not reference equality. Override equals.
  • Persist via mappers in infra. Custom transformers for JSON columns. Flat columns when you need to query parts.
  • Skip value objects for primitives where wrong values are loud and harmless. Use them where wrong values are quiet and expensive.
  • Idempotency keys are value objects in disguise. Treat them with the same discipline.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.