An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          

# @voznov/zod-dto

[![npm](https://img.shields.io/npm/v/@voznov/zod-dto.svg?label=%40voznov%2Fzod-dto&color=009454)](https://www.npmjs.com/package/@voznov/zod-dto)
[![npm](https://img.shields.io/npm/v/@voznov/zod-dto-nestjs.svg?label=%40voznov%2Fzod-dto-nestjs&color=009454)](https://www.npmjs.com/package/@voznov/zod-dto-nestjs)
[![License](https://img.shields.io/badge/license-Apache--2.0-009454.svg)](./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)