https://github.com/voznov/zod-dto
Zod to DTO classes for TypeScript & NestJS & OpenAPI
https://github.com/voznov/zod-dto
dto json-schema nestjs nestjs-zod openapi swagger typescript validation zod zod-dto
Last synced: 14 days ago
JSON representation
Zod to DTO classes for TypeScript & NestJS & OpenAPI
- Host: GitHub
- URL: https://github.com/voznov/zod-dto
- Owner: Voznov
- License: apache-2.0
- Created: 2026-04-20T22:51:32.000Z (2 months ago)
- Default Branch: master
- Last Pushed: 2026-06-06T19:55:37.000Z (14 days ago)
- Last Synced: 2026-06-06T21:01:35.347Z (14 days ago)
- Topics: dto, json-schema, nestjs, nestjs-zod, openapi, swagger, typescript, validation, zod, zod-dto
- Language: TypeScript
- Homepage:
- Size: 316 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Notice: NOTICE
Awesome Lists containing this project
README
# @voznov/zod-dto
[](https://www.npmjs.com/package/@voznov/zod-dto)
[](https://www.npmjs.com/package/@voznov/zod-dto-nestjs)
[](./LICENSE)
Turn a Zod 4 schema into a TypeScript class that's a DTO, a runtime validator, and an OpenAPI/Swagger source — all under one name. Drop-in for NestJS: `@Body() body: UserDto` validates and reifies the request, `@ZodResponse(UserDto)` validates the response and emits the spec.
Before:
```ts
class UserDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
id!: string;
@ApiProperty({ format: 'email' })
@IsEmail()
email!: string;
@ApiProperty({ minLength: 2, default: 'Anonymous', description: 'Display name' })
@IsString()
@MinLength(2)
@IsOptional()
name: string = 'Anonymous'; // runtime default lives separately from `@ApiProperty.default`
}
class CreateUserDto extends OmitType(UserDto, ['id'] as const) {} // mapped-type from @nestjs/swagger
@Controller('users')
class UsersController {
@Post()
@ApiOperation({ summary: 'Create user', description: 'Creates a user from the body payload' })
@ApiResponse({ status: 201, type: UserDto, description: 'Created user' })
create(@Body() body: CreateUserDto): UserDto {
return this.userService.create(body); // return shape is never checked at runtime
}
}
```
After:
```ts
class UserDto extends ZodDto(
z.object({
id: z.uuid(),
email: z.email(),
name: z.string().min(2).default('Anonymous').describe('Display name'),
}),
) {}
class CreateUserDto extends UserDto.omit({ id: true }) {}
@Controller('users')
class UsersController {
@Post()
@ZodResponse(
{ schema: UserDto, status: 201, description: 'Created user' },
{ summary: 'Create user', description: 'Creates a user from the body payload' },
)
create(@Body() body: CreateUserDto): UserDto {
return this.userService.create(body); // return is validated against the schema at runtime
}
}
```
The schema is the source of truth for type, validation, _and_ docs. `tsc` enforces the return type matches the schema; the OpenAPI spec is generated from the same Zod tree; `JSON.stringify` calls `out` if you set one (e.g. to strip `password`). Three orthogonal libraries (`class-validator` + `class-transformer` + `@nestjs/swagger`) collapse into one Zod expression — every change happens in one place, and `.default()` covers the runtime value *and* the spec at once.
## Beyond the basics
**Round-trip with `toDto` + `out` hook.** Outside controllers (webhooks, CLI, queue handlers) you still need parse-validate-reify in, and sensitive-field stripping on the way out. `class-validator` + `class-transformer` need both libraries plus a `ClassSerializerInterceptor` (or remembered `instanceToPlain()` calls). One `toDto(Dto, raw)` replaces the parse step; `out` becomes `toJSON` on the instance so plain `JSON.stringify` does the right thing everywhere (including nested DTOs). For repeated boundary work, `toDto.with({ preprocessors: [snakeToCamel], errorClass: DbValidationError })` preloads boundary-specific options once.
Before:
```ts
import { Exclude, instanceToPlain, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
class UserDto {
@IsUUID() id!: string;
@IsEmail() email!: string;
@IsString() @Exclude({ toPlainOnly: true }) password!: string;
}
async function processWebhook(payload: unknown): Promise {
const candidate = plainToInstance(UserDto, payload);
const errors = await validate(candidate);
if (errors.length > 0) throw new BadInput(errors.map((e) => `${e.property}: ${Object.values(e.constraints ?? {}).join(', ')}`));
return JSON.stringify(instanceToPlain(candidate)); // forget the wrap → password leaks
}
```
After:
```ts
import { toDto, ZodDto } from '@voznov/zod-dto';
class UserDto extends ZodDto(
z.object({ id: z.uuid(), email: z.email(), password: z.string() }),
{ out: ({ password, ...rest }) => rest },
) {}
function processWebhook(payload: unknown): string {
return JSON.stringify(toDto(UserDto, payload)); // throws ZodDtoValidationError; `out` strips password automatically
}
```
**Loose `@ZodResponse()` still catches response-shape bugs.** Class-validator's `@ApiResponse({ type: UserDto })` only paints the spec — the actual return value is sent to the client as-is. With `@ZodResponse()` the schema is resolved from the method's return type and the value is parsed at runtime; mismatches surface as `ZodDtoSerializationError` (a server-side 500) rather than leaking to the client.
Before:
```ts
@Get(':id')
@ApiResponse({ status: 200, type: UserDto }) // spec only — no runtime check
findOne(@Param() p: UserIdParam): UserDto {
return this.svc.findOne(p.id); // returns extra/missing/wrongly-typed fields? → sent as-is
}
```
After:
```ts
@Get(':id')
@ZodResponse() // schema resolved from `: UserDto`
findOne(@Param() p: UserIdParam): UserDto {
return this.svc.findOne(p.id); // mismatch → ZodDtoSerializationError with `path: message` issues
}
```
**Cross-field validation** — at-least-one-required, mutually-exclusive, dependent fields. With class-validator you write a custom `@ValidatorConstraint` class and bolt it on with a class-level decorator; Zod's object-level `.refine` is one line.
Before:
```ts
@ValidatorConstraint({ name: 'atLeastOne' })
class AtLeastOneConstraint implements ValidatorConstraintInterface {
validate(_: unknown, args: ValidationArguments) {
const o = args.object as ContactDto;
return Boolean(o.email || o.phone);
}
defaultMessage() { return 'email or phone is required'; }
}
function AtLeastOne(opts?: ValidationOptions) {
return function (target: object, propertyName: string) {
registerDecorator({ target: target.constructor, propertyName, options: opts, validator: AtLeastOneConstraint });
};
}
class ContactDto {
@ApiProperty({ format: 'email', required: false })
@IsOptional()
@IsEmail()
@AtLeastOne() // attaches to one field, but `validate` reads the whole object
email?: string;
@ApiProperty({ pattern: '^\\+\\d{6,}$', required: false })
@IsOptional()
@IsString()
phone?: string;
}
```
After:
```ts
class ContactDto extends ZodDto(
z
.object({
email: z.email().optional(),
phone: z.string().regex(/^\+\d{6,}$/).optional(),
})
.refine((d) => d.email || d.phone, { message: 'email or phone is required' }),
) {}
```
## Packages
| Package | Description |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [`@voznov/zod-dto`](packages/core/README.md) | Framework-agnostic core — `ZodDto`, `toDto`, `lazyDto`, `registerOnCreate`. |
| [`@voznov/zod-dto-nestjs`](packages/nestjs/README.md) | NestJS adapter — `ZodValidationPipe`, `@ZodSerialize`, `@ZodResponse`, automatic Swagger generation. |
## Example
A working NestJS server with CRUD + every edge case (async refines, discriminated unions, `.describe()` on nested DTOs, response validation, exception filter) lives in [`examples/notes-app`](examples/notes-app/README.md). `pnpm install && pnpm dev` and `http://localhost:3000/docs`.
## Why this over alternatives
- **One schema, three roles.** A `class extends ZodDto(z.object(...))` is the DTO type, the runtime validator, *and* the OpenAPI source. With `class-validator` + `class-transformer` + `@nestjs/swagger` you maintain three decorator stacks in three notations that drift apart at every change.
- **Composition on the DTO class, not on the schema.** `BaseDto.omit({ password: true })`, `.pick({...})`, `.extend({...})` return DTO classes directly — no `createZodDto(schema.omit(...))` ceremony like in `nestjs-zod`, no `OmitType`-mixin chains like in plain `@nestjs/swagger`.
- **Real compile-time return-type check.** `@ZodResponse(NoteDto)` constrains the method's return type via TypeScript overloads — `Promise` mismatches surface as `tsc` errors, not 500s in production. `nestjs-zod`'s TS-trick equivalent is brittle around `Promise` / arrays / unions.
- **Multi-response in one decorator.** `@ZodResponse([{ schema: NoteDto, status: 200 }, { throws: NotFoundError, status: 404 }])` emits both `@ApiResponse` entries, dispatches `res.status(...)` on the matched return, and validates `HttpException` bodies against the throws-spec at the matching status — neither `nestjs-zod` nor `@anatine/zod-nestjs` expose this.
- **Recursive schemas without the type-level dance.** `lazyDto(() => T)` builds self-referential DTOs (comment trees, file trees) that flow into OpenAPI as `$ref` cycles. Plain `z.lazy` works at runtime but loses the type signature; `@anatine/zod-nestjs` / `@palmetto/nestjs-zod-dto` don't model the cycle at all.
- **Per-DTO boundary transforms.** `in` runs as a Zod preprocessor before validation (`snake_case → camelCase` at the DB row); `out` becomes `toJSON` on the instance (strip `password` from every response without an interceptor). Swagger-only adapters like `@anatine/zod-nestjs` don't carry these.
- **Discriminator emission.** `z.discriminatedUnion('kind', [...])` of DTO classes emits a proper OpenAPI `discriminator` with mapping — codegen tools (`openapi-typescript`, `openapi-generator`) produce tagged unions, not structural ones.
- **Gradual migration.** `ZodValidationPipe` engages only on `ZodDto` parameters; class-validator DTOs in the same project keep working. Convert one DTO at a time.
## Scripts
```
pnpm build # build both packages (tsup: ESM + CJS + dts)
pnpm test # run vitest across the workspace
pnpm typecheck # tsc --noEmit per package
pnpm lint # eslint
pnpm check:all # typecheck + lint + test
```
## Contributing
Issues and PRs welcome.
## License
[Apache-2.0](./LICENSE)