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.createValueObjectwith 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 Object | Entity | |
|---|---|---|
Has identity (id) | No | Yes |
| Equality | Structural (all fields) | By identity (id) |
| Mutable | No | Yes (via actions) |
| Example | Money, Address, Email | User, 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
idbreaks that. UsecreateValueObjectwhen 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.