@ddd-ts-kit/validation

中文文档
0.106.0发布于 2026/11/01MITTypeScriptES Module构建来源已验证

One Schema contract for input validation, with adapters for zod, class-validator, TypeBox and Standard Schema.

原始使用文档
文档目录7 节
  1. The contract
  2. At the route
  3. zod
  4. class-validator
  5. TypeBox
  6. Standard Schema (valibot, arktype, ...)
  7. Shared schemas

English · Français

@ddd-ts-kit/validation

One Schema contract for input validation at the boundary, with adapters for zod, class-validator, TypeBox and any Standard Schema library. Routes, DTOs and the OpenAPI generator depend on the contract, so the validation library can change without touching them.

Business rules do not belong here: they live in the domain and never import a validation library.

A request at the boundary: its params, query, headers and body each go through the Schema the route declares; when every part parses, the handler receives typed values and the domain checks the business rules; when a part fails, its issues are collected with the name of the part in front of their path, such as body.items.0.sku, and the request is refused with one ValidationError, code VALIDATION_FAILED, answered 422 with the list of issues; the same schemas give the JSON Schema of the OpenAPI document

A schema checks the shape of what comes in: types, required fields, formats, lengths. What the business decides (a slot in the past, a quantity above the stock) is checked by the domain, whatever the entry point:

import { z } from 'zod';
import { DomainRefusal } from '@ddd-ts-kit/kernel';
import { zodSchema } from '@ddd-ts-kit/validation/zod';

// Instead of: a business rule hidden in the schema of one route, which a job or a consumer never meets.
export const bookWithARule = zodSchema(
  z.object({ startsAt: z.iso.datetime() }).refine((body) => Date.parse(body.startsAt) > Date.now(), 'The slot is in the past.'),
);

// Write: the shape in the schema...
export const book = zodSchema(z.object({ startsAt: z.iso.datetime() }));

// ...and the rule in the domain, with its own refusal code.
export const SchedulingCodes = { SlotInThePast: 'SLOT_IN_THE_PAST' } as const;

export class SlotInThePast extends DomainRefusal {
  readonly code = SchedulingCodes.SlotInThePast;

  static atStart(startsAt: Date) {
    return SlotInThePast.refusal(`The slot at ${startsAt.toISOString()} is in the past.`, {
      startsAt: startsAt.toISOString(),
    });
  }
}

export function startingAt(startsAt: Date, now: Date): Date {
  if (startsAt <= now) {
    throw SlotInThePast.atStart(startsAt);
  }

  return startsAt;
}

The contract

import { parseOrThrow, type Infer, type Schema } from '@ddd-ts-kit/validation';

interface Schema<T> {
  parse(input: unknown): Result<T, ValidationIssue[]>;
  toJsonSchema(): JsonSchema;
}

type ValidationIssue = { path: string; code: string; message: string }; // path: "items.0.sku"

A schema is a value: one per input, handed to the route or the event that validates with it. It is never bound in a container, so it is an interface and not an injectable port (an abstract class): there is nothing to declare in a composition root.

  • parse never throws for an invalid input: it returns err(issues).
  • parseOrThrow(schema, input) returns the value or throws a ValidationError, a DomainError with code VALIDATION_FAILED and the issues.
  • Infer<typeof schema> gives the parsed type.
  • customSchema(parse, jsonSchema?) builds a schema from a plain function.
  • sharedSchema(name, schema) documents a schema once, under name (see shared schemas).
import { err, ok } from '@ddd-ts-kit/kernel';
import { ValidationError, customSchema, parseOrThrow, type Infer } from '@ddd-ts-kit/validation';

// A schema from a plain function: a check written by hand, or a double in a test.
const quantity = customSchema(
  (input: unknown) =>
    typeof input === 'number' && Number.isInteger(input) && input > 0
      ? ok(input)
      : err([{ path: '', code: 'not_a_quantity', message: 'A positive whole number is expected.' }]),
  { type: 'integer', minimum: 1 },
);

type Quantity = Infer<typeof quantity>; // number

export const refused = quantity.parse('3'); // { ok: false, error: [{ path: '', code: 'not_a_quantity', ... }] }
export const three: Quantity = parseOrThrow(quantity, 3); // 3

try {
  parseOrThrow(quantity, 0);
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.code, error.issues); // VALIDATION_FAILED [{ path: '', code: 'not_a_quantity', ... }]
  }
}

The root entry imports no third-party package. Each adapter lives in its own subpath and its library is an optional peer dependency: install only the one you use.

At the route

A route of @ddd-ts-kit/http names a schema for each part of the request it reads. Every part is parsed before the handler runs, the issues of all parts are answered together, and each issue path starts with its part:

import { z } from 'zod';
import { defineRoute, executeRoute, type RawRequest } from '@ddd-ts-kit/http';
import { zodSchema } from '@ddd-ts-kit/validation/zod';

const placeOrder = defineRoute(
  {
    method: 'POST',
    path: '/orders',
    operationId: 'placeOrder',
    summary: 'Place an order',
    request: {
      body: zodSchema(z.object({ items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1) })),
    },
    responses: { 201: { description: 'The order is placed.', body: zodSchema(z.object({ lines: z.number() }), { io: 'output' }) } },
    security: false,
  },
  async ({ body }) => ({ status: 201, body: { lines: body.items.length } }), // body is typed: { items: { sku, quantity }[] }
);

const request = (body: unknown): RawRequest => ({ params: {}, query: {}, headers: {}, body, raw: undefined });

export const placed = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 2 }] })); // 201, { lines: 1 }
export const refused = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 0 }] }));
// 422, a problem with the code VALIDATION_FAILED and
// issues: [{ path: 'body.items.0.quantity', code: 'too_small', message: 'Too small: expected number to be >0' }]

The handler never sees an invalid request, and the OpenAPI document of the route describes the same body, from toJsonSchema().

zod

npm i zod
import { z } from 'zod';
import { zodSchema } from '@ddd-ts-kit/validation/zod';

declare const input: unknown; // what the request carried

const placeOrder = zodSchema(
  z.object({
    customerId: z.string().uuid(),
    items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
  }),
);

const result = placeOrder.parse(input);
if (!result.ok) {
  console.log(result.error); // [{ path: 'items.0.sku', code: 'too_small', message: '...' }]
}

The JSON Schema comes from zod 4's z.toJSONSchema (draft 2020-12, usable as is in OpenAPI 3.1). It describes the input side by default; pass { io: 'output' } for a response body. A type JSON Schema cannot represent (a Date, a bigint) is written as {} rather than failing the generation.

class-validator

npm i class-validator class-transformer class-validator-jsonschema reflect-metadata
import { Type } from 'class-transformer';
import { ArrayMinSize, IsInt, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { classSchema } from '@ddd-ts-kit/validation/class-validator';

class OrderItemDto {
  @IsString() declare sku: string;
  @IsInt() @Min(1) declare quantity: number;
}

class PlaceOrderDto {
  @IsUUID() declare customerId: string;

  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  @ArrayMinSize(1)
  declare items: OrderItemDto[];
}

export const placeOrder = classSchema(PlaceOrderDto);

parse returns an instance of the class. whitelist and forbidNonWhitelisted are on by default: an undeclared property is an issue. Override with classSchema(Dto, { validator: { forbidNonWhitelisted: false }, transform: { ... } }). Nested errors are flattened (items.0.quantity), one issue per failed constraint, the constraint name as code. The JSON Schema is draft 2020-12 and carries the nested classes reachable from the class under $defs.

Requires experimentalDecorators in the project's tsconfig.json.

Each field is declared, with no initial value. class-transformer keeps the initial value of a field the input lacks: customerId = '' under @IsString() would let an order without a customer pass, as if it named an empty one. An optional field is @IsOptional() with declare note?: string;. classSchema refuses a class whose fresh instance holds a value with a DeclarationError that names its fields and the fix.

A DTO class declared once is reused whole (classSchema(SlotDto, { shared: true }), or nested with @ValidateNested() and @Type, and @IsDefined() when it is required: nested validation lets a missing object through), extended (class BookBody extends SlotDto { ... }) or in part, through three helpers that copy the constraints, the nested types and the transformations of the properties they keep:

import { IsEmail, IsIn, IsISO8601, IsString, IsUUID } from 'class-validator';
import { omitClass, partialClass, pickClass } from '@ddd-ts-kit/validation/class-validator';

class AppointmentDto {
  @IsUUID() declare patientId: string;
  @IsISO8601() declare startsAt: string;
  @IsISO8601() declare endsAt: string;
  @IsIn(['booked', 'cancelled']) declare status: 'booked' | 'cancelled';
}

class PatientDto {
  @IsString() declare name: string;
  @IsEmail() declare email: string;
}

export class RescheduleBody extends pickClass(AppointmentDto, ['startsAt', 'endsAt']) {}
export class BookBody extends omitClass(AppointmentDto, ['status']) {}
export class EditPatientBody extends partialClass(PatientDto) {} // every property optional, checked when present

The class that extends a helper names the shape in the contract. Picking a property that carries no constraint is refused: validation would strip it as unknown. A derived class nested in another one through @Type declares at least one property of its own: class-validator-jsonschema documents a class only from its own decorators.

TypeBox

npm i typebox
import Type from 'typebox';
import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';

export const slot = Type.Object({ startsAt: Type.String({ format: 'date-time' }), minutes: Type.Integer({ default: 30 }) }, { $id: 'Slot' });
export const book = typeboxSchema(Type.Object({ patientId: Type.String({ minLength: 1 }), slot }));
export const page = typeboxSchema(Type.Object({ limit: Type.Integer() }), { coerce: true }); // query strings

The schema is compiled once (typebox/compile). parse fills the defaults and drops the properties the schema does not declare, as zod does, then checks; the issue code is the JSON Schema keyword that failed (required, format, minLength...). { coerce: true } converts text to the declared types first, for path parameters and query strings. A TypeBox schema is JSON Schema already: toJsonSchema() returns it, a nested schema built with an $id moved under $defs and referenced, so it lands once in the contract; a schema with its own $id is shared. A missing property is reported at its own path (slot.startsAt), as zod reports it, and two different schemas under one $id are refused. Type.Pick, Type.Omit and Type.Partial reuse a schema in part, as a new shape; Type.Intersect([slot, Type.Object({...})]) extends it and keeps the shared part a reference (allOf: [{ $ref: Slot }, ...]), while Type.Interface([slot], {...}) writes a new flat shape. (Type.Composite belongs to @sinclair/typebox 0.34 and does not exist in TypeBox 1.) Written for the typebox package, version 1 (the successor of @sinclair/typebox).

Standard Schema (valibot, arktype, ...)

import * as v from 'valibot';
import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';

export const placeOrder = standardSchema(
  v.object({ customerId: v.pipe(v.string(), v.uuid()) }),
  // Optional: the JSON Schema of the contract, which this adapter does not derive.
  { type: 'object', required: ['customerId'], properties: { customerId: { type: 'string', format: 'uuid' } } },
);

The adapter validates with ~standard.validate of Standard Schema v1. The JSON Schema of the contract is the second argument when it is given (written by hand, or produced by the exporter of your library); otherwise the one the library converts itself when it implements the companion specification, Standard JSON Schema v1 (~standard.jsonSchema; verified with zod 4, while arktype and valibot through toStandardJsonSchema of @valibot/to-json-schema announce it but are not verified here), input side by default and standardSchema(schema, undefined, { io: 'output' }) for a response; otherwise {}, which the OpenAPI document shows as "any value". A schema the library cannot write as JSON Schema (zod refuses z.date(), z.coerce.date(), z.custom) is documented {} as well: pass its JSON Schema as the second argument. The specification carries no error code, so every issue has the code invalid. A schema that validates asynchronously makes parse throw: async schemas are not supported at the boundary.

Shared schemas

A schema several routes use is declared once and documented once: the OpenAPI generator of @ddd-ts-kit/http writes it in components.schemas and points at it with $ref from every route, whole or nested. A schema is shared when its JSON Schema is a reference to one of its own definitions ({ $ref: '#/$defs/Slot', $defs: { Slot: ... } }), the form each library reaches its own way:

A schema shared under the name Slot: its JSON Schema is a reference to its own definition; the routes reschedule and book use it, one whole and one nested in its body; the OpenAPI generator writes Slot once under components.schemas and points at it with $ref from both routes, instead of two copies

import Type from 'typebox';
import * as v from 'valibot';
import { z } from 'zod';
import { sharedSchema } from '@ddd-ts-kit/validation';
import { classSchema } from '@ddd-ts-kit/validation/class-validator';
import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
import { zodSchema } from '@ddd-ts-kit/validation/zod';

declare class PatientDto {
  name: string;
  email: string;
}

// zod: an id in its metadata, kept when the schema is nested in another one
const slot = z.object({ startsAt: z.iso.datetime(), endsAt: z.iso.datetime() }).meta({ id: 'Slot' });
export const reschedule = zodSchema(slot);
export const book = zodSchema(z.object({ patientId: z.uuid(), slot }));

// class-validator: the name of the class
export const patient = classSchema(PatientDto, { shared: true });

// TypeBox: an $id in its options, kept when the schema is nested in another one
const money = Type.Object({ cents: Type.Integer(), currency: Type.String() }, { $id: 'Money' });
export const price = typeboxSchema(Type.Object({ amount: money }));

// any schema, the Standard Schema ones included
const amountSchema = v.object({ cents: v.pipe(v.number(), v.integer()) });
export const amount = sharedSchema('Amount', standardSchema(amountSchema));

sharedNameOf(jsonSchema) answers the name a JSON Schema is shared under, if any:

import { customSchema, sharedNameOf, sharedSchema } from '@ddd-ts-kit/validation';

const amount = sharedSchema('Amount', customSchema((input) => ({ ok: true, value: input }), { type: 'integer' }));

export const jsonSchema = amount.toJsonSchema(); // { $ref: '#/$defs/Amount', $defs: { Amount: { type: 'integer' } } }
export const name = sharedNameOf(jsonSchema); // 'Amount'

schemaBehind.of(schema) answers the schema sharedSchema was given, so that what another package attached to it (the fields withFieldAccess of @ddd-ts-kit/http reserves) holds for the shared one too.

A shared schema of the presentation layer lives where generate schema writes it: under presentation/http/schemas/ of its context, or under presentation/ of the shared kernel when several contexts answer with it; never in the domain.

版本与发布记录

@ddd-ts-kit/validation 共发布 63 个版本,首次发布于 2026/10/11;过去 12 个月发布了 63 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.106.0latest2026/11/012 天前
0.105.02026/10/312 天前
0.104.02026/10/313 天前
0.103.02026/10/313 天前
0.102.02026/10/313 天前
0.101.02026/10/303 天前

关于 @ddd-ts-kit/validation 的常见问题

@ddd-ts-kit/validation 是什么?

一个用于输入验证的模式契约,带有用于zod、类验证器、TypeBox和标准模式的适配器。

如何安装 @ddd-ts-kit/validation?

在项目目录运行 npm install @ddd-ts-kit/validation,或使用 pnpm add / yarn add / bun add。

@ddd-ts-kit/validation 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ddd-ts-kit/validation 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ddd-ts-kit/validation 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ddd-ts-kit/validation 可以商用吗?

@ddd-ts-kit/validation 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ddd-ts-kit/validation 还在维护吗?

最新版本 0.106.0 发布于 2026/11/01(2 天前);至今共发布 63 个版本,其中过去 12 个月发布了 63 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。