TypeScript DDD Value Object
MANDATORY — READ ENTIRE FILE: Before any implementation step, read
references/vo-pattern.md completely.
Do NOT load other DDD skills (entity, use-case) unless explicitly requested.
Where VOs live in this monorepo
| Scope | Path | Import from | Tests |
|---|---|---|---|
| Shared, cross-BC primitive | libs/shared/src/vo/<name>.vo.ts |
../base (inside lib) / @acme/shared (consumers) |
libs/shared/test/vo/<name>.vo.test.ts |
| BC-local (one bounded context only) | apps/api/src/<bc>/domain/value-objects/<name>.vo.ts |
@acme/shared |
apps/api/test/<bc>/domain/value-objects/<name>.vo.test.ts |
- Add a new shared VO to
libs/shared/src/vo/index.ts. - Inside
libs/sharedalways import the base via the relative../basebarrel — not via@acme/shared(self-import). - BC-local VOs live in
apps/apibecauselibs/sharedmust stay framework- and BC-vocabulary-free (project rule). - After a shared change, run
pnpm --filter @acme/shared buildand the consumers' checks (pnpm --filter api typecheck && pnpm --filter api test).
Before You Start
Answer first:
- Reuse or create? Can
Text(withminLength/maxLength) orNumber(withminValue/maxValue) cover this through config? Then no new VO — just callText.tryCreate(v, { minLength: 2, maxLength: 50 })inside the entity. - Closed set of allowed values? (e.g. palette key, kind, status, provider, layout). Then it is an enum-backed VO — see "Closed-set VOs use enums" below. Never represent a closed set as a raw-string
as consttuple. - Shape: scalar string/number/Date, or composite object (like
ImageRef)? Composite VOs collect errors and returnResult.fail(string[])instead of throwing. - Subclassable? Will other VOs extend this? →
protectedconstructor. Leaves →private. - Config: are constraints fixed or caller-configurable? → extend
ValueObjectConfigwith a typed interface. - Normalization: trim, lowercase, strip accents? → always normalize before validating, inside
tryCreate.
Constructor Visibility Decision
| VO intent | Constructor | Reason |
|---|---|---|
| Leaf VO — no subclasses needed | private |
Prevents unintended extension |
| Base VO — other VOs extend it | protected |
Subclasses call super(value, config) |
| Specialization — inherits error constant only | protected (from parent) |
Uses parent's tryCreate, overrides constant |
Text and Id both use protected — anything that might need specialization should too. Slug, PaletteKey, ImageRef, DotSeparatedName, Number are leaves and use private.
Closed-set VOs use enums (mandatory)
Every closed set of allowed values is a string-backed TS enum; the catalog tuple and the VO's stored type are derived from the enum. Never declare the set as a raw-string as const tuple, and never compare against string literals at call sites.
// libs/shared/src/vo/palette-key.vo.ts
import { Result, ValueObject, ValueObjectConfig } from "../base";
export enum PaletteKeyEnum {
BORDO = "bordo",
ROSE = "rose",
}
export const PALETTE_KEYS = Object.values(PaletteKeyEnum);
export type PaletteKey = (typeof PaletteKeyEnum)[keyof typeof PaletteKeyEnum];
export class PaletteKeyVO extends ValueObject<PaletteKey, ValueObjectConfig> {
private static readonly INVALID_PALETTE_KEY = "INVALID_PALETTE_KEY";
private constructor(value: PaletteKey, config?: ValueObjectConfig) {
super(value, config);
}
public static create(value: string, config?: ValueObjectConfig): PaletteKeyVO {
const r = PaletteKeyVO.tryCreate(value, config);
r.throwIfFailed();
return r.instance;
}
public static tryCreate(value: string, config?: ValueObjectConfig): Result<PaletteKeyVO> {
if (typeof value !== "string" || !PALETTE_KEYS.includes(value as PaletteKey)) {
return Result.fail(PaletteKeyVO.INVALID_PALETTE_KEY);
}
return Result.ok(new PaletteKeyVO(value as PaletteKey, config));
}
}
Call sites always compare via the enum:
if (palette.value === PaletteKeyEnum.BORDO) { ... } // OK
if (palette.value === "bordo") { ... } // FORBIDDEN
The live
libs/shared/src/vo/palette-key.vo.tsstill uses the oldPALETTE_KEYS as constform. New closed-set VOs must follow the enum pattern above; an existing one should be migrated when it is next touched.
Config Pattern
When constraints are caller-configurable, define a typed config interface:
import { Result, ValueObject, ValueObjectConfig } from "../base";
export interface MyVoConfig extends ValueObjectConfig {
minLength?: number;
maxLength?: number;
}
export class MyVo extends ValueObject<string, MyVoConfig> { ... }
Never pass raw ValueObjectConfig when your VO has domain-specific constraints — the type information is lost and callers can't discover options.
Core Rules
- Error codes are
private/protected static readonlystring constants — never inline strings inthrow/Result.fail. - Always normalize (trim, lowercase, NFD-strip accents) before checking invariants inside
tryCreate. create(value)is a thin wrapper: calltryCreate→throwIfFailed()→ return.instance. No logic there.- Scalar VOs may use
try/catch+Result.fail(error.message). Composite VOs (object value) collect errors into an array andreturn Result.fail(errors)— seeImageRef. - Closed-set VO → enum +
Object.values+(typeof Enum)[keyof typeof Enum]. Noas consttuples for new VOs. - Expose extra static methods (e.g.
normalize(),fromName(),withRandomSuffix(),required()) only when callers genuinely need them.
NEVER
- NEVER use a
privateconstructor on a VO designed to be extended — subclasssuper()will break at runtime. - NEVER inline error strings in
throw new Error("INVALID_X")orResult.fail("INVALID_X")— always reference a static constant. - NEVER validate before normalizing —
" Email@Example.com "must become"email@example.com"before the regex runs. - NEVER skip the
typeof + isNaNdouble guard on numeric VOs —typeof NaN === 'number'istrue. - NEVER model a closed value set as
["a", "b"] as constin a new VO — use a string enum +Object.values. - NEVER compare a VO's value against a string literal at the call site — compare against the enum member.
- NEVER duplicate a VO that
TextorNumberalready covers via config — preferText.tryCreate(v, { minLength: 2, maxLength: 50 }). - NEVER import a shared VO via
@acme/sharedfrom insidelibs/shareditself — use the relative../basebarrel.
References
See references/vo-pattern.md for: real file paths in this codebase, annotated code for each VO flavor (simple, parametric, numeric, canonical-form, closed-set/enum, composite, ID with required()), the import rules per scope, and the test coverage checklist.
See examples/ for a normalizing scalar VO (slug.vo.ts), a closed-set enum-backed VO (palette-key.vo.ts) with its test file (palette-key.vo.test.ts), and a BC-local numeric VO living inside apps/api/src/celebrations/domain/value-objects/ (celebration-slot-index.vo.ts + celebration-slot-index.vo.test.ts) demonstrating the dual API, config overrides, and the BC-local path placement rule.