agentsclimarketplace

Ts ddd value object

Skill llodev/skills/skills/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

Install
npx -y skills add llodev/skills --skill ts-ddd-value-object

Assembled 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

ScopePathImport fromTests
Shared, cross-BC primitivelibs/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/sharedapps/api/test/<bc>/domain/value-objects/<name>.vo.test.ts
  • Add a new shared VO to libs/shared/src/vo/index.ts.
  • Inside libs/shared always import the base via the relative ../base barrel — not via @acme/shared (self-import).
  • BC-local VOs live in apps/api because libs/shared must stay framework- and BC-vocabulary-free (project rule).
  • After a shared change, run pnpm --filter @acme/shared build and the consumers' checks (pnpm --filter api typecheck && pnpm --filter api test).

Before You Start

Answer first:

  • Reuse or create? Can Text (with minLength/maxLength) or Number (with minValue/maxValue) cover this through config? Then no new VO — just call Text.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 const tuple.
  • Shape: scalar string/number/Date, or composite object (like ImageRef)? Composite VOs collect errors and return Result.fail(string[]) instead of throwing.
  • Subclassable? Will other VOs extend this? → protected constructor. Leaves → private.
  • Config: are constraints fixed or caller-configurable? → extend ValueObjectConfig with a typed interface.
  • Normalization: trim, lowercase, strip accents? → always normalize before validating, inside tryCreate.

Constructor Visibility Decision

VO intentConstructorReason
Leaf VO — no subclasses neededprivatePrevents unintended extension
Base VO — other VOs extend itprotectedSubclasses call super(value, config)
Specialization — inherits error constant onlyprotected (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.ts still uses the old PALETTE_KEYS as const form. 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 readonly string constants — never inline strings in throw/Result.fail.
  • Always normalize (trim, lowercase, NFD-strip accents) before checking invariants inside tryCreate.
  • create(value) is a thin wrapper: call tryCreate → 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 and return Result.fail(errors) — see ImageRef.
  • Closed-set VO → enum + Object.values + (typeof Enum)[keyof typeof Enum]. No as const tuples for new VOs.
  • Expose extra static methods (e.g. normalize(), fromName(), withRandomSuffix(), required()) only when callers genuinely need them.

NEVER

  • NEVER use a private constructor on a VO designed to be extended — subclass super() will break at runtime.
  • NEVER inline error strings in throw new Error("INVALID_X") or Result.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 + isNaN double guard on numeric VOs — typeof NaN === 'number' is true.
  • NEVER model a closed value set as ["a", "b"] as const in 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 Text or Number already covers via config — prefer Text.tryCreate(v, { minLength: 2, maxLength: 50 }).
  • NEVER import a shared VO via @acme/shared from inside libs/shared itself — use the relative ../base barrel.

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

references/

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.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.