npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@voznov/zod-dto-nestjs

v0.4.2

Published

NestJS adapter for @voznov/zod-dto — validation pipe, Swagger generation

Downloads

539

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 an onCreate hook that decorates every ZodDto-derived class with @ApiProperty metadata 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.
  • @ZodResponse with 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 as tsc errors instead of 500s in production. Multi-response (@ZodResponse([{ schema: A, status: 200 }, { throws: NotFoundError, status: 404 }])) emits one ApiResponse per entry, dispatches res.status(...) on the matched return spec, and validates HttpException bodies against throws-specs at matching status.

Install

pnpm add @voznov/zod-dto-nestjs @voznov/zod-dto @nestjs/common @nestjs/swagger zod

Quick 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 / out hooks are runtime-only. The walker only reads the schema's structure, not the options passed to ZodDto(schema, { in, out }). A out: ({ password, ...rest }) => rest correctly strips password from the response body, but the OpenAPI schema still lists password as a property — the spec lies about a field that runtime drops. Same for in: snake_case→camelCase aliases applied via in are 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 @ApiResponse Swagger 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:

  1. Response spec — what shape the route returns / throws. Accepts one of:
    • a Zod schema or DTO class — simple form, defaults status to 200;
    • a ResponseSpec object — { schema?, throws?, status?, description? } plus ToDtoOptions (preprocessors, observers, errorClass);
    • an array of ResponseSpec — multi-response: one @ApiResponse per entry, the return value is parsed against the return-specs, thrown HttpException bodies are validated against the throws-specs at the matching status (see "throws-marked specs" below);
    • undefined (loose form) — resolves the schema from design:returntype.
  2. Operation metadataApiOperationOptions (summary, tags, description, deprecated, operationId, ...). Forwarded straight to @ApiOperation so 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 single description: string slot 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 constraintfindOne(): NoteDto compiles 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 Error doesn'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. |

License

Apache-2.0