createValueObject(config)

Creates a Value Object class — objects defined by their attributes, not by identity. Two Value Objects with identical props are equal.

Signature

function createValueObject<
  TProps,
  TActions extends Record<string, (state: TProps, ...args: any[]) => void> = {},
  TComputed extends Record<string, (state: TProps) => any> = {},
>(config: ValueObjectConfig<TProps, TActions, TComputed>): ValueObjectClass

Config

PropertyTypeDescription
validate(data: unknown) => TPropsValidates raw input. Returns typed props or throws.
actionsRecord<string, (draft, ...args) => void>Optional. Domain actions that produce a new state.
computedRecord<string, (props) => any>Derived getters.

Returned Class

MemberTypeDescription
VO.create(data)ValueObjectStatic factory. Validates input.
vo.propsReadonly<TProps>Frozen snapshot of current state.
vo.actions.*(...args) => voidAction methods (if configured).
vo.equals(other)booleanStructural equality — compares JSON of props.

Example

import { createValueObject } from "@sotajs/ddd";

const Money = createValueObject({
  validate: (data) => {
    const d = data as Record<string, unknown>;
    if (typeof d.amount !== "number") throw new Error("amount must be number");
    if (d.amount < 0) throw new Error("amount must be ≥ 0");
    return { amount: d.amount, currency: d.currency as string };
  },

  actions: {
    add: (draft, other: { amount: number; currency: string }) => {
      if (draft.currency !== other.currency) throw new Error("Currency mismatch");
      draft.amount += other.amount;
    },
  },

  computed: {
    formatted: (state) => `${state.amount} ${state.currency}`,
  },
});

const a = Money.create({ amount: 100, currency: "USD" });
const b = Money.create({ amount: 100, currency: "USD" });
const c = Money.create({ amount: 200, currency: "USD" });

console.log(a.equals(b)); // true  — same attributes
console.log(a.equals(c)); // false — different amount

a.actions.add({ amount: 50, currency: "USD" });
console.log(a.props.amount);  // 150
console.log(a.formatted);     // "150 USD"

// Currency mismatch throws
// a.actions.add({ amount: 50, currency: "EUR" });

Structural Equality

Value Objects use JSON.stringify for equality comparison. This means:

  • Property order in objects matters
  • undefined values are stripped by JSON.stringify
  • Functions and Symbols are ignored

For most Value Objects this is sufficient. If you need custom equality logic, implement it outside the VO.

Actions Are Optional

Actions on Value Objects are less common than on Entities. A Value Object without actions is perfectly valid:

const Point = createValueObject({
  validate: (data) => {
    const d = data as Record<string, unknown>;
    return { x: d.x as number, y: d.y as number };
  },
});

const p = Point.create({ x: 1, y: 2 });
// p.actions is an empty object

Pitfalls

  • Equality uses JSON. If your Value Object contains Date, BigInt, or circular references, equals() will behave unexpectedly.
  • No identity. Value Objects don’t have an id — they are their attributes.
  • Immutability. Props are frozen. To “change” a VO, produce a new instance or use an action that mutates the internal state.