What you’ll learn

  • What a Value Object is and how it differs from an Entity
  • How structural equality works
  • When to use Value Object vs Entity
  • Common mistakes: type aliases, unnecessary IDs, treating everything as Entity

Concept

A Value Object is defined by its attributes, not by an identity. Two value objects with the same fields are equal:

const a = Money.create({ amount: 100, currency: 'USD' });
const b = Money.create({ amount: 100, currency: 'USD' });
a.equals(b); // true — same value

const c = Money.create({ amount: 100, currency: 'EUR' });
a.equals(c); // false — different currency

Creating a Value Object

import { z } from 'zod';
import { createValueObject } from '@sotajs/ddd';

const MoneySchema = z.object({
  amount: z.number().positive(),
  currency: z.enum(['USD', 'EUR', 'RUB']),
});

const Money = createValueObject({ schema: MoneySchema });
type Money = ReturnType<typeof Money.create>;

Value Objects are immutable. To “change” one, create a new instance:

const price = Money.create({ amount: 100, currency: 'USD' });
const discounted = Money.create({ amount: 80, currency: 'USD' });

Using a type alias as a Value Object:

type Money = { amount: number; currency: string };

function charge(amount: Money) {
  // Is currency one of the allowed values? No — it's just a type alias.
  // { amount: -500, currency: 'BTC' } passes.
}

type Money = { ... } is a TypeScript fiction. At runtime it’s just an object. createValueObject with Zod catches invalid values at creation time.

Giving a Value Object an id:

const MoneySchema = z.object({
  id: z.string(),  // ❌ value objects don't have identity
  amount: z.number(),
});

Two Money objects with the same amount but different ids are not equal — you made an Entity by accident.

When to use Value Object vs Entity

Value ObjectEntity
Has identity (id)NoYes
EqualityStructural (all fields)By identity (id)
MutableNoYes (via actions)
ExampleMoney, Address, EmailUser, Order, Product

Making everything an Entity:

const Address = createEntity({
  schema: z.object({ id: z.string().uuid(), street: z.string(), city: z.string() }),
  actions: {},
});

const a = Address.create({ id: 'abc', street: '123 Main', city: 'NYC' });
const b = Address.create({ id: 'xyz', street: '123 Main', city: 'NYC' });
a.equals(b); // false — different ids, same address

Two identical addresses should be equal. An id breaks that. Use createValueObject when the concept has no identity of its own.

Value Objects as entity fields

const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string(),
  address: MoneySchema, // ← Value Object schema as a field
});

What you’ve learned

Value Objects compare by structure, not identity. Two instances with the same fields are equal. There is no id — the value IS the identity.

Value Objects are immutable. No actions, no auto-setters. To “change” one, create a new instance. This models concepts that don’t change independently: money amounts, addresses, coordinates.

Don’t use type aliases. type Money = { ... } has zero runtime validation. createValueObject with Zod catches invalid values at creation.

Don’t give Value Objects an id. If two identical values aren’t equal because their ids differ, you made an Entity. Value Objects have no identity.

What’s next?

Branded IDs prevent mixing up UserId and OrderId at compile time. They’re strings at runtime, distinct types to TypeScript. Branded IDs covers how to create them, why they matter, and where to use them.

Branded IDs