Ts ddd value object
Create, review, or guide Value Object implementation in a TypeScript + DDD codebase. Use when the request involves 'value object', 'VO', `*.vo.ts` files (either shared at `libs/shared/src/vo/...` or BC-local at `apps/api/src/<bc>/domain/value-objects/...`), domain attribute validation/normalization, closed-set VOs derived from string enums (PaletteKey-style), composite-value VOs (ImageRef-style), `ValueObject` + `Result` pattern, `ValueObjectConfig`, `tryCreate`/`create` dual API, or VO test coverage.From its SKILL.md
npx -y skills add llodev/skills --skill ts-ddd-value-objectAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 2 commands, including `pnpm --filter @acme/shared build` and 1 more.
SKILL.md
8.7 KB, ~1.9k tokens by cl100k_base, as published. Nobody here has run it
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 protected] "must become"[email protected]"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.
What ships with it: 12 files
54.1 KB alongside SKILL.md, 5 of them executable
docs/
- i18n/README.es-ES.md6.5 KB
- i18n/README.pt-BR.md6.3 KB
examples/
- celebration-slot-index.vo.test.tsruns2.1 KB
- celebration-slot-index.vo.tsruns2.7 KB
- palette-key.vo.test.tsruns2.3 KB
- palette-key.vo.tsruns2.0 KB
- slug.vo.tsruns3.1 KB
references/
- vo-pattern.md20.4 KB
- CHANGELOG.md1014 B
- LICENSE1.1 KB
- package.json788 B
- README.md5.9 KB
Gives 0 of the 12 instructions most test skills give in ~1.9k tokens
Counted across 1,201 of the 2,096 authors here whose files we hold, read 2026-09-06
- Write a failing test before writing codein 43 of 1201, across 36 files
- Run the full test suitein 36 of 1201, across 35 files
- Test only one variable per experimentin 34 of 1201, across 17 files
- Read product marketing context before asking questionsin 34 of 1201, across 14 files
- Mock external dependenciesin 34 of 1201, across 30 files
- Define primary, secondary, and guardrail metricsin 33 of 1201, across 16 files
- Pre-determine sample size before startingin 31 of 1201, across 14 files
- Test behavior rather than implementationin 31 of 1201, across 29 files
- Formulate a hypothesis before designing a testin 30 of 1201, across 13 files
- Document every test hypothesis, variant, and resultin 29 of 1201, across 11 files
- Use descriptive test function namesin 25 of 1201, across 21 files
- Commit to the methodology without stopping earlyin 24 of 1201, across 8 files
Said here and by no other author read
- Use string-backed enums for closed-set fields
- Read the domain service pattern reference before implementation
- Normalize values before validating them in tryCreate
- Define a typed config interface for configurable constraints
- Use private constructors for leaf value objects
- Use protected constructors for base value objects
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.