@voznov/zod-dto-nestjs
v0.4.2
Published
NestJS adapter for @voznov/zod-dto — validation pipe, Swagger generation
Downloads
539
Maintainers
Readme
@voznov/zod-dto-nestjs
NestJS adapter for @voznov/zod-dto — validation pipe + automatic Swagger integration.
Highlights
- Auto-Swagger without
@ApiProperty. Importing this package registers anonCreatehook that decorates everyZodDto-derived class with@ApiPropertymetadata derived from its Zod tree — no manual decorators, no schema duplication. Nested DTOs, discriminated unions, defaults (z.default), descriptions (z.describe), and recursive cycles (lazyDto) all forward into the spec automatically. Import order doesn't matter — DTOs created before the import are retroactively decorated. @ZodResponsewith compile-time return-type check. A single decorator validates the response at runtime and emits@ApiResponse/@ApiOperation. The method's return type is constrained against the schema via TypeScript overloads —Promise<NoteDto[]>mismatches surface astscerrors instead of 500s in production. Multi-response (@ZodResponse([{ schema: A, status: 200 }, { throws: NotFoundError, status: 404 }])) emits oneApiResponseper entry, dispatchesres.status(...)on the matched return spec, and validatesHttpExceptionbodies against throws-specs at matching status.
Install
pnpm add @voznov/zod-dto-nestjs @voznov/zod-dto @nestjs/common @nestjs/swagger zodQuick start
Register the pipe globally, then use DTO classes as controller parameter types:
// main.ts
import { ZodValidationPipe } from '@voznov/zod-dto-nestjs';
app.useGlobalPipes(new ZodValidationPipe());import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ZodDto } from '@voznov/zod-dto';
import { z } from 'zod';
class CreateUserDto extends ZodDto(z.object({ name: z.string(), email: z.email() })) {}
class UserIdParam extends ZodDto(z.object({ id: z.uuid() })) {}
class ListUsersQuery extends ZodDto(
z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
) {}
@Controller('users')
export class UsersController {
@Post()
create(@Body() body: CreateUserDto) {
// already validated; `body` is a CreateUserDto instance.
}
// `@Param() params: UserIdParam` — `id` validated as UUID, format: 'uuid' in the spec.
// (Cleaner than `@Param('id', ParseUUIDPipe) id: string`, and the spec carries the format.)
@Get(':id')
findOne(@Param() params: UserIdParam) {
/* ... */
}
// `@Query() query: ListUsersQuery` — every Zod field becomes one OpenAPI query parameter,
// with per-field validation, defaults, and descriptions, no extra `@ApiQuery` decorators needed.
@Get()
list(@Query() query: ListUsersQuery) {
/* ... */
}
}Gradual migration
ZodValidationPipe only engages when the parameter's metatype is a ZodDtoClass — every other type (primitives, plain classes, class-validator DTOs with ValidationPipe) passes through untouched. Safe to register globally alongside an existing setup; convert DTOs one at a time.
Swagger integration
Importing this package side-effect-registers an onCreate hook that decorates every DTO class with @ApiProperty metadata based on its Zod schema — no manual decorators required. Import order doesn't matter: DTOs created before @voznov/zod-dto-nestjs was imported are retroactively decorated at hook registration, so import '@voznov/zod-dto-nestjs' from anywhere in the app works.
Supported shapes: scalars, objects (nested), arrays, tuples, records, enums, literals, unions (including discriminated), intersections, optional/nullable/default wrappers, and nested DTO references (via oneOf + ApiExtraModels).
z.discriminatedUnion(key, [...]) of DTO classes emits a proper OpenAPI discriminator: { propertyName, mapping } alongside oneOf, so codegen tools (openapi-typescript, openapi-generator) generate tagged unions instead of structural ones. Falls back to plain oneOf if any variant can't be mapped (non-DTO class or non-literal discriminator field).
.default(value) is forwarded to the OpenAPI default keyword.
⚠️ Lazy defaults are frozen at decoration time. For
.default(() => ...), the thunk is invoked once when the swagger metadata is generated, and the resolved value is baked into the spec. Anything non-stable —Date.now(),randomUUID(),new Date()— will freeze at the value the server happened to produce on startup, and every endpoint's example in your docs will show that one stale value. Use a stable thunk, or a literal default.
.describe(text) is forwarded to the OpenAPI description keyword. Works on the wrapper or on the inner type — z.string().describe('Login email').optional() and z.string().optional().describe('Login email') both end up with description: 'Login email' in the spec.
.refine(...) validators run at runtime (in ZodValidationPipe for requests, in @ZodSerialize / @ZodResponse for responses) but are not reflected in the spec — JSON Schema can't express custom predicates, and a single description for a chain of refines (.refine(...).refine(...).refine(...)) would be ambiguous. Put human-readable docs in .describe(...) instead.
⚠️
in/outhooks are runtime-only. The walker only reads the schema's structure, not the options passed toZodDto(schema, { in, out }). Aout: ({ password, ...rest }) => restcorrectly stripspasswordfrom the response body, but the OpenAPI schema still listspasswordas a property — the spec lies about a field that runtime drops. Same forin: snake_case→camelCase aliases applied viainare invisible in the spec, so docs show only the camelCase shape. If spec-correctness matters, either omit the field from the schema itself (schema.omit({ password: true })) or maintain a separate response DTO.
Reference nested DTOs by class, not by raw schema
// ❌ Inlines NoteDto's full shape into items[]; codegen produces two separate types for the same data.
class PaginatedNotes extends ZodDto(z.object({ items: z.array(noteSchema) })) {}
// ✅ Emits `items: { type: 'array', items: { $ref: '#/components/schemas/NoteDto' } }`.
class PaginatedNotes extends ZodDto(z.object({ items: z.array(NoteDto) })) {}Both forms parse identically, but only the second one keeps the spec DRY — $ref instead of an inlined copy. Use the DTO class itself in nested positions whenever you have one.
Recursive schemas (z.lazy / lazyDto)
For self-referential shapes (comment trees, file trees, ...) wrap the recursion in a DTO and reference it back via lazyDto — the Swagger walker emits a proper $ref at the cycle, and lazyDto keeps TypeScript from tripping over the circular self-reference:
import { lazyDto, ZodDto } from '@voznov/zod-dto';
class CategoryDto extends ZodDto(
z.object({
name: z.string(),
children: z.array(lazyDto<CategoryDto>(() => CategoryDto)),
}),
) {}
// → children items become `$ref: '#/components/schemas/CategoryDto'`
// → at the type level, `instance.children[0].name` is `string`, not `unknown`.lazyDto<T>(thunk) is a thin wrapper over z.lazy with two type-level tweaks: the explicit generic carries the instance type T, and the thunk argument is typed () => any so TS skips body return-type inference (the source of the circular-class error). Plain z.lazy(...) works at runtime too, but you'd need a separate type Category = {...} + (): z.ZodType<Category> annotation to keep the inferred field types non-unknown.
If the recursive position resolves to an anonymous ZodObject (no DTO wrap), the walker emits an empty {} placeholder there — it won't crash, but Swagger UI will show any instead of the recursive structure. Wrap it in ZodDto if you want the cycle visible in your docs.
Custom error response
ZodValidationPipe accepts { createError }. Default throws BadRequestException with the issues list as its response body.
import { HttpException, HttpStatus } from '@nestjs/common';
app.useGlobalPipes(
new ZodValidationPipe({
createError: (issues) =>
new HttpException({ statusCode: 400, error: 'Bad Request', errors: issues }, HttpStatus.BAD_REQUEST),
}),
);Response validation — @ZodSerialize / @ZodResponse
Method decorators that parse the return value of a controller (or any class) method through a Zod schema. If the method returns something that doesn't match the schema, a ZodDtoSerializationError is thrown — caught at runtime before the value reaches the client, so server-side bugs are surfaced as 500s instead of leaking malformed payloads or extra fields.
@ZodSerialize— runtime parsing only. Use on services, repositories, internal methods.@ZodResponse—@ZodSerialize+ auto-emit@ApiResponseSwagger metadata (and register inner DTOs via@ApiExtraModels). Use on controller routes.
ZodDtoSerializationError extends ZodDtoValidationError — wire up one exception filter to split client errors (400) from server bugs (500):
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { ZodDtoValidationError } from '@voznov/zod-dto';
import { ZodDtoSerializationError } from '@voznov/zod-dto-nestjs';
@Catch(ZodDtoValidationError)
export class ZodExceptionFilter implements ExceptionFilter {
catch(error: ZodDtoValidationError, host: ArgumentsHost) {
const isServerBug = error instanceof ZodDtoSerializationError;
const status = isServerBug ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST;
host
.switchToHttp()
.getResponse()
.status(status)
.json({
statusCode: status,
message: error.message,
issues: isServerBug ? undefined : error.issues,
});
}
}
// main.ts
app.useGlobalFilters(new ZodExceptionFilter());@ZodSerialize has two overloads — strict (schema given explicitly, return-type compile-time-checked against the schema) and loose (no schema, resolves from design:returntype at runtime). @ZodResponse adds two more on top: a ResponseSpec object form ({ schema, status?, description?, throws?, ...ToDtoOptions }) and a ResponseSpec[] array form for multi-response.
Common pattern: pass the schema explicitly when the return type isn't a bare DTO class. Loose form sees Promise / Array constructors stripped of their generic argument, so : Promise<NoteDto> / : NoteDto[] won't resolve. The strict form errors as TS1241: Unable to resolve signature of method decorator... on schema/return mismatch — the actual mismatch is on the deepest line of the message (Type 'X' is not assignable to type 'NoteDto | Promise<NoteDto>').
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ZodResponse } from '@voznov/zod-dto-nestjs';
import { z } from 'zod';
@Controller('notes')
export class NotesController {
// Strict + auto-Swagger: tsc enforces the return type, spec gets `$ref` to NoteDto.
@Get(':id')
@ZodResponse(NoteDto)
findOne(@Param() p: NoteIdParam): NoteDto {
/* ... */
}
// Async + array: tsc enforces `Promise<NoteDto[]>`. Generics erased in design:returntype,
// so the schema must be passed explicitly here.
@Get()
@ZodResponse(z.array(NoteDto))
async list(): Promise<NoteDto[]> {
/* ... */
}
// Override status (default 200) + description via the spec object form. Second argument
// is the ApiOperation metadata (summary / tags / deprecated / operationId / description).
@Post()
@ZodResponse({ schema: NoteDto, status: 201, description: 'note created' }, { summary: 'Create a note' })
create(@Body() body: CreateNoteDto): NoteDto {
/* ... */
}
// Multi-response: 200 returns the note, 404 throws `HttpException(notFoundBody, 404)` and we
// validate that the thrown body matches `NotFoundDto` (shape drift surfaces as ZodDtoSerializationError).
// `throws:` schemas are NOT in the method's return-type union — the method just returns `NoteDto`.
@Get('many/:id')
@ZodResponse(
[
{ schema: NoteDto, status: 200, description: 'Found' },
{ throws: NotFoundDto, status: 404, description: 'Not found' },
],
{ summary: 'Get a note' },
)
findOneOrThrow(@Param() p: NoteIdParam): NoteDto {
/* throws HttpException(..., 404) on miss */
}
}Runtime-only sibling for layers below the controller — same overloads, no Swagger emission:
import { ZodSerialize } from '@voznov/zod-dto-nestjs';
class NotesService {
// Throws ZodDtoSerializationError if the return shape doesn't match.
@ZodSerialize(NoteDto)
findOne(id: string): NoteDto {
/* ... */
}
// Loose: schema resolved from `design:returntype` (NoteDto class). Won't work for `Promise<...>` / `NoteDto[]` — generic erased to `Promise` / `Array`. Use the strict overload for those.
@ZodSerialize()
default(): NoteDto {
/* ... */
}
}Options
Both decorators accept the full ToDtoOptions bag (preprocessors, observers, errorClass — same semantics as toDto.with, but applied to the method's return value instead of an input). @ZodSerialize takes them as its second argument; @ZodResponse takes them inside the ResponseSpec (per-status). The default errorClass is ZodDtoSerializationError (vs ZodDtoValidationError for toDto), so an exception filter can split client errors from server bugs (see below).
@ZodResponse takes two arguments:
- Response spec — what shape the route returns / throws. Accepts one of:
- a Zod schema or DTO class — simple form, defaults
statusto 200; - a
ResponseSpecobject —{ schema?, throws?, status?, description? }plusToDtoOptions(preprocessors,observers,errorClass); - an array of
ResponseSpec— multi-response: one@ApiResponseper entry, the return value is parsed against the return-specs, thrownHttpExceptionbodies are validated against the throws-specs at the matching status (see "throws-marked specs" below); undefined(loose form) — resolves the schema fromdesign:returntype.
- a Zod schema or DTO class — simple form, defaults
- Operation metadata —
ApiOperationOptions(summary,tags,description,deprecated,operationId, ...). Forwarded straight to@ApiOperationso you don't need a second decorator for the common case. An explicit@ApiOperation({...})stacked on top still wins on conflicting keys.
@ZodResponse({ schema: NoteDto, status: 201, observers: [(note) => metrics.recordCreate(note)] }, { summary: 'Create a note', tags: ['notes'] })
async create(): Promise<NoteDto> { /* ... */ }Note on
description. In the response spec it's the response description (the OpenAPI status object's description). In the operation argument it's the operation description (the long-form route description). The split is intentional — the previous singledescription: stringslot was ambiguous.
Throws-marked specs — validate HttpException body against a schema
In a ResponseSpec array, { throws: ErrorDto, status: 4xx } declares that when the method throws an HttpException whose status matches, its body must match ErrorDto. Shape drift → ZodDtoSerializationError (a server-side 500 — your declared error contract no longer matches what the code actually throws). On match the original exception re-throws untouched, so global exception filters still own the wire format.
@Get(':id')
@ZodResponse([
{ schema: NoteDto, status: 200 },
{ throws: NotFoundError, status: 404 }, // method throws HttpException({code, message}, 404) on miss
])
findOne(@Param() p: NoteIdParam): NoteDto { // return-type union is just NoteDto — throws-spec doesn't widen it
const note = this.svc.find(p.id);
if (!note) throw new HttpException({ code: 'NOT_FOUND', message: `note ${p.id} missing` }, 404);
return note;
}Key properties:
throws:schemas are excluded from the method's return-type constraint —findOne(): NoteDtocompiles even when the array also declares{throws: NotFoundError, status: 404}.- HttpException with a status that's not declared as throws passes through — typical
throw new BadRequestException(...)(400) won't be touched unless you declared a{throws: ..., status: 400}spec. - Non-HttpException throws pass through unchanged — vanilla
Errordoesn't get validated.
Behind the scenes
@ZodResponse doesn't wrap the method; it auto-registers a per-method ZodResponseInterceptor via UseInterceptors. After a successful return-side parse the interceptor writes the response directly via the platform's native chain (Express res.status(s).json(b) or Fastify reply.code(s).send(b), picked by feature detection), then emits EMPTY so NestJS's default applicationRef.reply() doesn't run a second res.status(verbDefault) over the dispatched status. Direct-write is the only reliable way to dispatch a non-default status on a per-spec basis: NestJS's httpStatusCode is resolved once at bootstrap from @HttpCode metadata or the verb default and re-applied to the response on every request, so a runtime res.status(...) from inside map() would be clobbered.
Composition with other interceptors
@ZodResponse is a terminal response contract — what it declared in @ApiResponse (status + body schema) must match what actually goes out, otherwise the Swagger doc lies to clients. So the interceptor owns the write end-to-end and, by design, doesn't cooperate with interceptors that would alter the body or status after it.
What composes and what doesn't:
| Interceptor pattern | Works? | Why |
| --------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Latency / metrics via finalize | ✅ | EMPTY completes cleanly through .pipe(...), finalize fires on completion. |
| tap({ complete }) / tap({ error }) observers | ✅ | Same — completion / error propagate; only next is suppressed. |
| Body-reading loggers (tap(body => log(body))) | ❌ | next never fires; the body has already been written by @ZodResponse. |
| Body-transforming wrappers (envelope, msgpack, ...) | ❌ | If inner: their transform never runs (EMPTY). If outer: they wrap the value before @ZodResponse parses → ZodDtoSerializationError. |
| Header / status mutators outside @ZodResponse | ❌ | The status @ZodResponse dispatched is the contract; later mutators would desync it from the Swagger declaration. |
Concrete observability — drop in as a class- or global-level interceptor; works on every route, with or without @ZodResponse:
import { type CallHandler, type ExecutionContext, Injectable, Logger, type NestInterceptor } from '@nestjs/common';
import { type Observable, tap } from 'rxjs';
@Injectable()
export class LatencyInterceptor implements NestInterceptor {
private readonly logger = new Logger(LatencyInterceptor.name);
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = process.hrtime.bigint();
const req = ctx.switchToHttp().getRequest<{ method: string; url: string }>();
return next.handle().pipe(
tap({
// `complete` fires on the inner Observable's completion. `@ZodResponse` emits `EMPTY` after writing
// the response, which completes immediately — so this callback runs for every `@ZodResponse` route.
complete: () =>
this.logger.log(`${req.method} ${req.url} → ${Number(process.hrtime.bigint() - start) / 1e6} ms`),
}),
);
}
}If you need to read the response body downstream (audit logging, response tracing), @ZodResponse doesn't expose the parsed value to outer interceptors — use observers: [...] on the spec (@ZodResponse({ schema: Dto, observers: [(parsed) => audit.log(parsed)] })) or hook on res.on('finish') / reply.then(...) at the platform layer.
If you need to transform the body (envelope, msgpack), the contract you declared in @ApiResponse would no longer match the wire shape — bake the envelope into the schema instead (z.object({ data: Dto, meta: MetaSchema })), then @ZodResponse documents and validates the actual outgoing shape. That's the only way to keep the Swagger doc honest.
Async refines on the response schema
When the method returns a Promise, the decorator awaits it and parses through safeParseAsync — that's the only signal it uses. So if your schema has async validation (z.string().refine(async ...), async transforms — typical when the same schema is reused for request validation), make the method async and you'll get the async parse path; failures still throw ZodDtoSerializationError and flow through your exception filter, not raw Zod async errors.
class Repo {
// Promise return → safeParseAsync. Works with async refines on the schema.
@ZodSerialize(SchemaWithAsyncRefine)
async findOne(id: string): Promise<...> { /* ... */ }
}API
| Export | Description |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ZodValidationPipe | PipeTransform for @Body() / @Param() / @Query(). Accepts { createError?: (issues: string[]) => Error }. |
| ZodValidationPipeOptions | Options type for ZodValidationPipe. |
| ZodSerialize(schema?, options?) | Method decorator: runtime-parse the return value through schema (or via design:returntype if omitted). Throws ZodDtoSerializationError on mismatch. |
| ZodResponse(response, operation?) | Auto-registers ZodResponseInterceptor for runtime validation + writes res.status(...).json(...) directly, emits @ApiResponse (and @ApiExtraModels for inner DTOs). First arg: schema / ResponseSpec / ResponseSpec[] / undefined. Second arg: ApiOperationOptions. |
| ResponseSpec<S, T> | { schema?: S; throws?: T; status?: number; description?: string } & ToDtoOptions — entry shape for @ZodResponse's first argument. schema validates returns, throws validates HttpException.getResponse() at matching status. |
| ZodResponseInterceptor | Per-method NestInterceptor auto-registered by @ZodResponse. Exposed for manual app.useGlobalInterceptors(...) use if needed. |
| ZodDtoSerializationError | Subclass of ZodDtoValidationError thrown when a method's return value or a throws-spec's HttpException body fails validation. |
| applySwaggerDecorators(schema) | Low-level: apply @ApiProperty metadata to a schema. Auto-invoked via registerOnCreate; export is for manual/edge-case use. |
