npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vytches/ddd-value-objects

v0.31.1

Published

Enhanced value objects and EntityId implementations

Readme

@vytches/ddd-value-objects

npm version TypeScript License: MIT

Base value object class, EntityId, and branded ID types

Provides the foundation for value objects in DDD — immutability, structural equality, and type-safe entity identifiers.

Installation

pnpm add @vytches/ddd-value-objects

What's included

| Export | Kind | Description | | ------------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BaseValueObject<T> | class | Abstract base for value objects; deep-freezes internal value; implements equals() via structural comparison, with an opt-in getIdentityComponents() hook for partial-identity comparison | | ValueObjectValidator<T> | interface | validate(value: T): boolean — implement to add invariant checking | | EntityId<T> | class | Enhanced entity identifier extending the contracts EntityId; adds UUID/integer/text validation, create(), fromString(), fromNumber() | | EntityIdFactory | class | Deprecated — will be removed in v1.0.0; use EntityId.create() directly | | BrandedId<Tag> | type | Compile-time branded EntityId<string> — prevents mixing IDs across aggregate types | | createBrandedId<Tag>(id) | function | Casts an EntityId to BrandedId<Tag> | | newBrandedId<Tag>() | function | Creates a new UUID-based BrandedId<Tag> | | brandedIdFromUUID<Tag>(uuid) | function | Creates a BrandedId<Tag> from an existing UUID string | | brandedIdFromText<Tag>(text) | function | Creates a BrandedId<Tag> from a text string | | IEntityId<T> | interface | Re-exported from @vytches/ddd-contracts | | IEntityIdFactory | interface | Re-exported from @vytches/ddd-contracts | | IdType | type | Re-exported from @vytches/ddd-contracts'uuid' \| 'integer' \| 'text' |

Note on bundled domain examples

This package does not export Email, Money, Address, PhoneNumber, DateRange, or other concrete domain value objects. Those are application-level types — implement them in your own domain layer using BaseValueObject as the base class.

Quick start

Custom value object

import { BaseValueObject } from '@vytches/ddd-value-objects';

interface MoneyProps {
  amount: number;
  currency: string;
}

class Money extends BaseValueObject<MoneyProps> {
  constructor(amount: number, currency: string) {
    super({ amount, currency });
  }

  validate(value: MoneyProps): boolean {
    return value.amount >= 0 && value.currency.length === 3;
  }

  get amount(): number {
    return this.value.amount;
  }
  get currency(): string {
    return this.value.currency;
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('Currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }
}

const price = new Money(10, 'USD');
const tax = new Money(1.5, 'USD');
const total = price.add(tax);

console.log(total.equals(new Money(11.5, 'USD'))); // true

EntityId

import { EntityId } from '@vytches/ddd-value-objects';

// Generate a new UUID-based ID
const id = EntityId.create(); // EntityId<string> with UUID

// From an existing UUID string
const fromString = EntityId.fromString('550e8400-e29b-41d4-a716-446655440000');

// From an integer
const fromNumber = EntityId.fromNumber(42);

console.log(id.getValue()); // '550e8400-...'
console.log(id.equals(EntityId.fromString(id.getValue()))); // true

Branded IDs

import {
  BrandedId,
  newBrandedId,
  brandedIdFromUUID,
} from '@vytches/ddd-value-objects';

type OrderId = BrandedId<'Order'>;
type CustomerId = BrandedId<'Customer'>;

const orderId: OrderId = newBrandedId<'Order'>();
const customerId: CustomerId = newBrandedId<'Customer'>();

function shipOrder(id: OrderId): void {
  /* ... */
}

shipOrder(orderId); // OK
shipOrder(customerId); // TypeScript compile error!

Keeping a specific validation error with getInvalidValueMessage()

Since VF-023 — the constructor validates, see CHANGELOG.md.

BaseValueObject's constructor calls validate() and throws on failure, before the subclass constructor body runs. validate() returns a boolean, so any value object whose real validation produces a rich error — which field, which rule, which expected checksum — has to swallow that error to satisfy the signature. What reaches the caller is then the generic default:

Error: Invalid value for Regon

Override getInvalidValueMessage() to put the specific message back. It is called only on the failing path, so re-running the detailed validation there costs nothing in the happy case:

export abstract class NationalIdentifier extends BaseValueObject<IdentifierProps> {
  /** Throws a rich, typed error. Called by validate() and, on failure, here. */
  protected abstract assertValid(value: string): void;

  validate(props: IdentifierProps): boolean {
    try {
      this.assertValid(props.value);
      return props.locale === this.expectedLocale;
    } catch {
      return false; // the detail is lost here — recovered below
    }
  }

  protected override getInvalidValueMessage(props: IdentifierProps): string {
    try {
      this.assertValid(props.value);
    } catch (error) {
      // "REGON has invalid checksum: expected 5, got 6"
      return error instanceof Error ? error.message : String(error);
    }
    return `${this.typeName} requires locale '${this.expectedLocale}', got '${props.locale}'`;
  }
}

Two constraints apply, both from the undefined-during-super() trap: the override may read only its value parameter and state the base constructor has already set (this.value is assigned before the throw), never a field initialized in the subclass constructor — that field is still undefined here.

A Result-returning factory keeps the typed error by re-wrapping what the constructor threw:

static create(value: string): Result<Regon, IdentifierValidationError> {
  try {
    return Result.ok(new Regon({ value, locale: 'pl-PL' }));
  } catch (error) {
    if (error instanceof IdentifierValidationError) return Result.fail(error);
    // Generic Error from the base constructor — message already specific,
    // thanks to getInvalidValueMessage() above.
    return Result.fail(
      new IdentifierValidationError(
        error instanceof Error ? error.message : 'Unknown error',
        'REGON'
      )
    );
  }
}

Migration note. Before VF-023 the common shape was new X(props) followed by x.assertValid(...) in the factory — the constructor did not validate, so the factory's own call produced the error. That second call is now unreachable: the base class throws first. A factory relying on it silently changes its error type from the typed one to a plain Error.

Partial-identity equality with getIdentityComponents()

Since VF-036 — additive minor, see CHANGELOG.md.

By default, equals() compares the entire stored value: === for primitives, LibUtils.deepEqual for objects. Override the protected getIdentityComponents() hook when only a subset of a value object's state should participate in equality — for example, a Money value object that carries a display-formatting flag that must not affect equality, or a value object that should compare a derived/normalized projection of its state rather than the raw stored shape.

import { BaseValueObject } from '@vytches/ddd-value-objects';

interface MoneyProps {
  amount: number;
  currency: string;
  displayFormat: 'symbol' | 'code';
}

class Money extends BaseValueObject<MoneyProps> {
  constructor(props: MoneyProps) {
    super(props);
  }

  validate(value: MoneyProps): boolean {
    return (
      typeof value === 'object' &&
      value !== null &&
      value.amount >= 0 &&
      value.currency.length === 3
    );
  }

  // 'Money' is a type-scoped discriminator (see below); displayFormat is
  // intentionally excluded from identity.
  protected override getIdentityComponents(): readonly unknown[] {
    return ['Money', this.value.amount, this.value.currency];
  }
}

const a = new Money({ amount: 10, currency: 'USD', displayFormat: 'symbol' });
const b = new Money({ amount: 10, currency: 'USD', displayFormat: 'code' });

a.equals(b); // true — displayFormat is not an identity component

When to reach for this instead of full-value equality

Use getIdentityComponents() when a value object's raw stored value carries fields that are incidental to identity — audit metadata, cache keys, presentation flags — while the value the domain actually cares about is narrower. If every field of value matters for equality, leave the hook unimplemented; the default undefined return keeps the unmodified raw comparison.

The asymmetric fallback — and why it breaks transitivity in mixed populations

equals() calls getIdentityComponents() on both sides being compared. Component comparison only runs if both sides return a defined array; if either side returns undefined, equals() falls back to the unchanged raw comparison. This fallback is symmetric — a.equals(b) and b.equals(a) always agree, since both directions see the same two results — but it is deliberately not transitive once a raw-comparison instance and a component-override instance coexist in the same population:

  • A — no override, raw comparison.
  • B — component override, whose raw value happens to equal A's.
  • C — component override, whose components match B's.

A.equals(B) can be true (raw fallback matches), B.equals(C) can be true (components match), yet A.equals(C) can be false (raw fallback, different value). This is an accepted, test-pinned limitation — it is not "fixed" with instanceof/type gating.

Collection-level consequence: code such as list.some(x => x.equals(y)) or de-duplication by .equals() implicitly assumes equality is an equivalence relation — reflexive, symmetric, and transitive. Mixing component-identity subclasses with raw-comparison subclasses of the same base type inside one collection breaks that assumption silently. Migrate a whole class hierarchy to getIdentityComponents() together, or not at all — see the root MIGRATION.md for the atomic-codemod guidance.

The empty-array footgun

Returning [] is defined-and-empty, not "opt out." Two value objects that both return [] are equal to each other regardless of what their value holds (same length — zero — so the element-wise comparison is vacuously true for both). To opt out of component comparison, return undefined (the base default); never return [] to mean "not applicable."

The fixed-arity rule

A class must always return the same number of components, in the same order. Comparison starts with a length check, so a conditional push — if (this.scope) parts.push(this.scope) — makes two instances of the same class unequal purely because one had an optional field set. That reads as a data difference when it is actually an arity difference. Push a stable placeholder instead:

import { BaseValueObject } from '@vytches/ddd-value-objects';

interface GrantProps {
  tenant: string;
  scope?: string;
  key: string;
}

class GrantRef extends BaseValueObject<GrantProps> {
  validate(value: GrantProps): boolean {
    return typeof value === 'object' && value !== null;
  }

  protected override getIdentityComponents(): readonly unknown[] {
    // Always three slots, whatever is populated. Never push conditionally.
    return [this.value.tenant, this.value.scope ?? null, this.value.key];
  }
}

The same applies across a hierarchy: if a subclass adds a component, it has changed the arity of every comparison against its parent — a design decision to make deliberately rather than discover.

The "sometimes undefined" downgrade trap

Only a literal undefined/omitted return from the hook itself triggers the raw-comparison fallback — a component value inside a returned array being undefined (e.g. an unset optional field) does not; the array is still "defined" and comparison proceeds element-wise. The trap is the reverse case: a conditional override — "return components once some field is set, otherwise return undefined" — will silently and intermittently downgrade the very same class to raw comparison across different instances or different points in an instance's lifecycle. Prefer an override that always returns an array once a class opts in.

Throw propagation — equals() is no longer total

A getIdentityComponents() override that throws propagates straight out of equals(), uncaught. This is deliberate: equals() is a hot path, and this library's internal logger is diagnostics-only and not meant to intercept consumer logic (see the logging-removal note in the root MIGRATION.md), so silently swallowing the throw into a wrong-but-quiet false/true would hide a real bug in the override. Once a subclass overrides this hook with logic that can throw, equals() is no longer a total function for that subclass.

Components must come from frozen or readonly state

The base constructor deep-freezes the value passed to super() — but it does not freeze any additional fields a subclass declares and assigns after super(value); those remain ordinary mutable class fields unless the subclass itself keeps them readonly/frozen. Derive components only from state that cannot change after construction (the frozen value, or subclass fields the subclass itself keeps immutable). Deriving a component from mutable state breaks the invariant that a value object's equality is stable for its lifetime.

Type-scoped equality: the sanctioned idiom

getIdentityComponents() performs no type or instanceof check — two different subclasses returning matching components are still considered equal, exactly as cross-subclass raw comparison behaves today. If equality should be scoped to a specific type, add a string-literal discriminator as the first component (as in the Money example above):

import { BaseValueObject } from '@vytches/ddd-value-objects';

class OrderNumber extends BaseValueObject<string> {
  validate(value: unknown): boolean {
    return typeof value === 'string' && value.length > 0;
  }

  protected override getIdentityComponents(): readonly unknown[] {
    return ['OrderNumber', this.value];
  }
}

Do not use this.constructor.name (unsafe under minification, which can rename classes inconsistently) and do not use the class object itself (unsafe across duplicate package copies — the same hazard instanceof has).

toString()/toJSON() stay value-based

Overriding getIdentityComponents() does not change toString() or toJSON() — both continue to serialize the raw value, unfiltered. If your override narrows equality to a subset of fields, serialization may now surface information that equality ignores (or the reverse). Keeping any resulting equals/serialization desync coherent is the consumer's responsibility.

A permanent note: getEqualityComponents was never real. An early (2025) documentation draft described a similarly-named getEqualityComponents() hook. It was never implemented in any released version of this library — every equals() call has always used the raw value-based comparison described above. The name was removed from docs in a later accuracy pass, but some consumers had already written overrides against it, which were (and always had been) dead code. getIdentityComponents() is the real, supported hook, under a deliberately different name. getEqualityComponents will not be added as an alias, shim, or runtime-detected fallback — doing so would silently activate every dormant consumer override on upgrade, which is exactly the behavioral break this design avoids. If you are holding a dead getEqualityComponents override, see the root MIGRATION.md.

Package boundaries

@vytches/ddd-value-objects depends on:

  • @vytches/ddd-contractsEntityId base class, IEntityId, IdType
  • @vytches/ddd-domain-primitives — error types
  • @vytches/ddd-utilsLibUtils, Result

License

MIT