@voznov/zod-dto-nestjs 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ NestJS adapter for [`@voznov/zod-dto`](https://www.npmjs.com/package/@voznov/zod
5
5
  ## Highlights
6
6
 
7
7
  - **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.
8
- - **`@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.
8
+ - **`@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.
9
9
 
10
10
  ## Install
11
11
 
@@ -30,10 +30,12 @@ import { z } from 'zod';
30
30
 
31
31
  class CreateUserDto extends ZodDto(z.object({ name: z.string(), email: z.email() })) {}
32
32
  class UserIdParam extends ZodDto(z.object({ id: z.uuid() })) {}
33
- class ListUsersQuery extends ZodDto(z.object({
34
- page: z.coerce.number().int().min(1).default(1),
35
- limit: z.coerce.number().int().min(1).max(100).default(20),
36
- })) {}
33
+ class ListUsersQuery extends ZodDto(
34
+ z.object({
35
+ page: z.coerce.number().int().min(1).default(1),
36
+ limit: z.coerce.number().int().min(1).max(100).default(20),
37
+ }),
38
+ ) {}
37
39
 
38
40
  @Controller('users')
39
41
  export class UsersController {
@@ -45,12 +47,16 @@ export class UsersController {
45
47
  // `@Param() params: UserIdParam` — `id` validated as UUID, format: 'uuid' in the spec.
46
48
  // (Cleaner than `@Param('id', ParseUUIDPipe) id: string`, and the spec carries the format.)
47
49
  @Get(':id')
48
- findOne(@Param() params: UserIdParam) { /* ... */ }
50
+ findOne(@Param() params: UserIdParam) {
51
+ /* ... */
52
+ }
49
53
 
50
54
  // `@Query() query: ListUsersQuery` — every Zod field becomes one OpenAPI query parameter,
51
55
  // with per-field validation, defaults, and descriptions, no extra `@ApiQuery` decorators needed.
52
56
  @Get()
53
- list(@Query() query: ListUsersQuery) { /* ... */ }
57
+ list(@Query() query: ListUsersQuery) {
58
+ /* ... */
59
+ }
54
60
  }
55
61
  ```
56
62
 
@@ -74,7 +80,7 @@ Supported shapes: scalars, objects (nested), arrays, tuples, records, enums, lit
74
80
 
75
81
  `.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.
76
82
 
77
- > ⚠️ **`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.
83
+ > ⚠️ **`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.
78
84
 
79
85
  ### Reference nested DTOs by class, not by raw schema
80
86
 
@@ -105,7 +111,7 @@ class CategoryDto extends ZodDto(
105
111
  // → at the type level, `instance.children[0].name` is `string`, not `unknown`.
106
112
  ```
107
113
 
108
- `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`.
114
+ `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`.
109
115
 
110
116
  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.
111
117
 
@@ -126,7 +132,7 @@ app.useGlobalPipes(
126
132
 
127
133
  ## Response validation — `@ZodSerialize` / `@ZodResponse`
128
134
 
129
- 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.
135
+ 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.
130
136
 
131
137
  - **`@ZodSerialize`** — runtime parsing only. Use on services, repositories, internal methods.
132
138
  - **`@ZodResponse`** — `@ZodSerialize` + auto-emit `@ApiResponse` Swagger metadata (and register inner DTOs via `@ApiExtraModels`). Use on controller routes.
@@ -143,11 +149,15 @@ export class ZodExceptionFilter implements ExceptionFilter {
143
149
  catch(error: ZodDtoValidationError, host: ArgumentsHost) {
144
150
  const isServerBug = error instanceof ZodDtoSerializationError;
145
151
  const status = isServerBug ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST;
146
- host.switchToHttp().getResponse().status(status).json({
147
- statusCode: status,
148
- message: error.message,
149
- issues: isServerBug ? undefined : error.issues,
150
- });
152
+ host
153
+ .switchToHttp()
154
+ .getResponse()
155
+ .status(status)
156
+ .json({
157
+ statusCode: status,
158
+ message: error.message,
159
+ issues: isServerBug ? undefined : error.issues,
160
+ });
151
161
  }
152
162
  }
153
163
 
@@ -169,19 +179,25 @@ export class NotesController {
169
179
  // Strict + auto-Swagger: tsc enforces the return type, spec gets `$ref` to NoteDto.
170
180
  @Get(':id')
171
181
  @ZodResponse(NoteDto)
172
- findOne(@Param() p: NoteIdParam): NoteDto { /* ... */ }
182
+ findOne(@Param() p: NoteIdParam): NoteDto {
183
+ /* ... */
184
+ }
173
185
 
174
186
  // Async + array: tsc enforces `Promise<NoteDto[]>`. Generics erased in design:returntype,
175
187
  // so the schema must be passed explicitly here.
176
188
  @Get()
177
189
  @ZodResponse(z.array(NoteDto))
178
- async list(): Promise<NoteDto[]> { /* ... */ }
190
+ async list(): Promise<NoteDto[]> {
191
+ /* ... */
192
+ }
179
193
 
180
194
  // Override status (default 200) + description via the spec object form. Second argument
181
195
  // is the ApiOperation metadata (summary / tags / deprecated / operationId / description).
182
196
  @Post()
183
197
  @ZodResponse({ schema: NoteDto, status: 201, description: 'note created' }, { summary: 'Create a note' })
184
- create(@Body() body: CreateNoteDto): NoteDto { /* ... */ }
198
+ create(@Body() body: CreateNoteDto): NoteDto {
199
+ /* ... */
200
+ }
185
201
 
186
202
  // Multi-response: 200 returns the note, 404 throws `HttpException(notFoundBody, 404)` and we
187
203
  // validate that the thrown body matches `NotFoundDto` (shape drift surfaces as ZodDtoSerializationError).
@@ -194,7 +210,9 @@ export class NotesController {
194
210
  ],
195
211
  { summary: 'Get a note' },
196
212
  )
197
- findOneOrThrow(@Param() p: NoteIdParam): NoteDto { /* throws HttpException(..., 404) on miss */ }
213
+ findOneOrThrow(@Param() p: NoteIdParam): NoteDto {
214
+ /* throws HttpException(..., 404) on miss */
215
+ }
198
216
  }
199
217
  ```
200
218
 
@@ -206,17 +224,21 @@ import { ZodSerialize } from '@voznov/zod-dto-nestjs';
206
224
  class NotesService {
207
225
  // Throws ZodDtoSerializationError if the return shape doesn't match.
208
226
  @ZodSerialize(NoteDto)
209
- findOne(id: string): NoteDto { /* ... */ }
227
+ findOne(id: string): NoteDto {
228
+ /* ... */
229
+ }
210
230
 
211
231
  // 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.
212
232
  @ZodSerialize()
213
- default(): NoteDto { /* ... */ }
233
+ default(): NoteDto {
234
+ /* ... */
235
+ }
214
236
  }
215
237
  ```
216
238
 
217
239
  ### Options
218
240
 
219
- Both decorators accept the full `ToDtoOptions` bag (`preprocessors`, `observers`, `errorClass` — same semantics as [`toDto.with`](https://www.npmjs.com/package/@voznov/zod-dto), 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).
241
+ Both decorators accept the full `ToDtoOptions` bag (`preprocessors`, `observers`, `errorClass` — same semantics as [`toDto.with`](https://www.npmjs.com/package/@voznov/zod-dto), 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).
220
242
 
221
243
  `@ZodResponse` takes two arguments:
222
244
 
@@ -259,7 +281,51 @@ Key properties:
259
281
 
260
282
  ### Behind the scenes
261
283
 
262
- `@ZodResponse` doesn't wrap the method; it auto-registers a per-method `ZodResponseInterceptor` via `UseInterceptors`. The interceptor takes over response writing: after a successful return-side parse it calls `res.status(spec.status).json(parsedBody)` directly, then emits `EMPTY` so NestJS's default rendering doesn't run a second `res.status(verbDefault)` over it. This is the only reliable way to dispatch a non-default status (`@Get` defaults 200, `@Post` 201, etc. Nest writes those after interceptors). Trade-off: upstream interceptors receive `EMPTY` rather than the parsed body, and custom body-renderer chains (msgpack/yaml/...) are bypassed. For typical JSON APIs it's the right default; if you need composability with response transformations, hold off on `@ZodResponse` for those routes.
284
+ `@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.
285
+
286
+ ### Composition with other interceptors
287
+
288
+ `@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.
289
+
290
+ What composes and what doesn't:
291
+
292
+ | Interceptor pattern | Works? | Why |
293
+ | --------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
294
+ | Latency / metrics via `finalize` | ✅ | `EMPTY` completes cleanly through `.pipe(...)`, `finalize` fires on completion. |
295
+ | `tap({ complete })` / `tap({ error })` observers | ✅ | Same — completion / error propagate; only `next` is suppressed. |
296
+ | Body-reading loggers (`tap(body => log(body))`) | ❌ | `next` never fires; the body has already been written by `@ZodResponse`. |
297
+ | Body-transforming wrappers (envelope, msgpack, ...) | ❌ | If inner: their transform never runs (`EMPTY`). If outer: they wrap the value before `@ZodResponse` parses → `ZodDtoSerializationError`. |
298
+ | Header / status mutators outside `@ZodResponse` | ❌ | The status `@ZodResponse` dispatched is the contract; later mutators would desync it from the Swagger declaration. |
299
+
300
+ Concrete observability — drop in as a class- or global-level interceptor; works on every route, with or without `@ZodResponse`:
301
+
302
+ ```ts
303
+ import { type CallHandler, type ExecutionContext, Injectable, Logger, type NestInterceptor } from '@nestjs/common';
304
+ import { type Observable, tap } from 'rxjs';
305
+
306
+ @Injectable()
307
+ export class LatencyInterceptor implements NestInterceptor {
308
+ private readonly logger = new Logger(LatencyInterceptor.name);
309
+
310
+ intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
311
+ const start = process.hrtime.bigint();
312
+ const req = ctx.switchToHttp().getRequest<{ method: string; url: string }>();
313
+
314
+ return next.handle().pipe(
315
+ tap({
316
+ // `complete` fires on the inner Observable's completion. `@ZodResponse` emits `EMPTY` after writing
317
+ // the response, which completes immediately — so this callback runs for every `@ZodResponse` route.
318
+ complete: () =>
319
+ this.logger.log(`${req.method} ${req.url} → ${Number(process.hrtime.bigint() - start) / 1e6} ms`),
320
+ }),
321
+ );
322
+ }
323
+ }
324
+ ```
325
+
326
+ 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.
327
+
328
+ 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.
263
329
 
264
330
  ### Async refines on the response schema
265
331
 
@@ -275,16 +341,16 @@ class Repo {
275
341
 
276
342
  ## API
277
343
 
278
- | Export | Description |
279
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
280
- | `ZodValidationPipe` | `PipeTransform` for `@Body()` / `@Param()` / `@Query()`. Accepts `{ createError?: (issues: string[]) => Error }`. |
281
- | `ZodValidationPipeOptions` | Options type for `ZodValidationPipe`. |
282
- | `ZodSerialize(schema?, options?)` | Method decorator: runtime-parse the return value through `schema` (or via `design:returntype` if omitted). Throws `ZodDtoSerializationError` on mismatch. |
344
+ | Export | Description |
345
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
346
+ | `ZodValidationPipe` | `PipeTransform` for `@Body()` / `@Param()` / `@Query()`. Accepts `{ createError?: (issues: string[]) => Error }`. |
347
+ | `ZodValidationPipeOptions` | Options type for `ZodValidationPipe`. |
348
+ | `ZodSerialize(schema?, options?)` | Method decorator: runtime-parse the return value through `schema` (or via `design:returntype` if omitted). Throws `ZodDtoSerializationError` on mismatch. |
283
349
  | `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`. |
284
- | `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. |
285
- | `ZodResponseInterceptor` | Per-method `NestInterceptor` auto-registered by `@ZodResponse`. Exposed for manual `app.useGlobalInterceptors(...)` use if needed. |
286
- | `ZodDtoSerializationError` | Subclass of `ZodDtoValidationError` thrown when a method's return value or a throws-spec's `HttpException` body fails validation. |
287
- | `applySwaggerDecorators(schema)` | Low-level: apply `@ApiProperty` metadata to a schema. Auto-invoked via `registerOnCreate`; export is for manual/edge-case use. |
350
+ | `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. |
351
+ | `ZodResponseInterceptor` | Per-method `NestInterceptor` auto-registered by `@ZodResponse`. Exposed for manual `app.useGlobalInterceptors(...)` use if needed. |
352
+ | `ZodDtoSerializationError` | Subclass of `ZodDtoValidationError` thrown when a method's return value or a throws-spec's `HttpException` body fails validation. |
353
+ | `applySwaggerDecorators(schema)` | Low-level: apply `@ApiProperty` metadata to a schema. Auto-invoked via `registerOnCreate`; export is for manual/edge-case use. |
288
354
 
289
355
  ## License
290
356