{"id":50630690,"url":"https://github.com/voznov/zod-dto","last_synced_at":"2026-06-06T21:01:36.580Z","repository":{"id":352745849,"uuid":"1216436855","full_name":"Voznov/zod-dto","owner":"Voznov","description":"Zod to DTO classes for TypeScript \u0026 NestJS \u0026 OpenAPI","archived":false,"fork":false,"pushed_at":"2026-06-06T19:55:37.000Z","size":324,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-06T21:01:35.347Z","etag":null,"topics":["dto","json-schema","nestjs","nestjs-zod","openapi","swagger","typescript","validation","zod","zod-dto"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Voznov.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-20T22:51:32.000Z","updated_at":"2026-06-06T19:53:47.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Voznov/zod-dto","commit_stats":null,"previous_names":["voznov/zod-dto"],"tags_count":15,"template":false,"template_full_name":null,"purl":"pkg:github/Voznov/zod-dto","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Voznov%2Fzod-dto","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Voznov%2Fzod-dto/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Voznov%2Fzod-dto/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Voznov%2Fzod-dto/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Voznov","download_url":"https://codeload.github.com/Voznov/zod-dto/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Voznov%2Fzod-dto/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33999593,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-06T02:00:07.033Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["dto","json-schema","nestjs","nestjs-zod","openapi","swagger","typescript","validation","zod","zod-dto"],"created_at":"2026-06-06T21:00:51.800Z","updated_at":"2026-06-06T21:01:36.574Z","avatar_url":"https://github.com/Voznov.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @voznov/zod-dto\n\n[![npm](https://img.shields.io/npm/v/@voznov/zod-dto.svg?label=%40voznov%2Fzod-dto\u0026color=009454)](https://www.npmjs.com/package/@voznov/zod-dto)\n[![npm](https://img.shields.io/npm/v/@voznov/zod-dto-nestjs.svg?label=%40voznov%2Fzod-dto-nestjs\u0026color=009454)](https://www.npmjs.com/package/@voznov/zod-dto-nestjs)\n[![License](https://img.shields.io/badge/license-Apache--2.0-009454.svg)](./LICENSE)\n\nTurn 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.\n\nBefore:\n\n```ts\nclass UserDto {\n  @ApiProperty({ format: 'uuid' })\n  @IsUUID()\n  id!: string;\n\n  @ApiProperty({ format: 'email' })\n  @IsEmail()\n  email!: string;\n\n  @ApiProperty({ minLength: 2, default: 'Anonymous', description: 'Display name' })\n  @IsString()\n  @MinLength(2)\n  @IsOptional()\n  name: string = 'Anonymous';                       // runtime default lives separately from `@ApiProperty.default`\n}\n\nclass CreateUserDto extends OmitType(UserDto, ['id'] as const) {}   // mapped-type from @nestjs/swagger\n\n@Controller('users')\nclass UsersController {\n  @Post()\n  @ApiOperation({ summary: 'Create user', description: 'Creates a user from the body payload' })\n  @ApiResponse({ status: 201, type: UserDto, description: 'Created user' })\n  create(@Body() body: CreateUserDto): UserDto {\n    return this.userService.create(body);          // return shape is never checked at runtime\n  }\n}\n```\n\nAfter:\n\n```ts\nclass UserDto extends ZodDto(\n  z.object({\n    id: z.uuid(),\n    email: z.email(),\n    name: z.string().min(2).default('Anonymous').describe('Display name'),\n  }),\n) {}\nclass CreateUserDto extends UserDto.omit({ id: true }) {}\n\n@Controller('users')\nclass UsersController {\n  @Post()\n  @ZodResponse(\n    { schema: UserDto, status: 201, description: 'Created user' },\n    { summary: 'Create user', description: 'Creates a user from the body payload' },\n  )\n  create(@Body() body: CreateUserDto): UserDto {\n    return this.userService.create(body);          // return is validated against the schema at runtime\n  }\n}\n```\n\nThe 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.\n\n## Beyond the basics\n\n**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.\n\nBefore:\n\n```ts\nimport { Exclude, instanceToPlain, plainToInstance } from 'class-transformer';\nimport { validate } from 'class-validator';\n\nclass UserDto {\n  @IsUUID() id!: string;\n  @IsEmail() email!: string;\n  @IsString() @Exclude({ toPlainOnly: true }) password!: string;\n}\n\nasync function processWebhook(payload: unknown): Promise\u003cstring\u003e {\n  const candidate = plainToInstance(UserDto, payload);\n  const errors = await validate(candidate);\n  if (errors.length \u003e 0) throw new BadInput(errors.map((e) =\u003e `${e.property}: ${Object.values(e.constraints ?? {}).join(', ')}`));\n\n  return JSON.stringify(instanceToPlain(candidate));  // forget the wrap → password leaks\n}\n```\n\nAfter:\n\n```ts\nimport { toDto, ZodDto } from '@voznov/zod-dto';\n\nclass UserDto extends ZodDto(\n  z.object({ id: z.uuid(), email: z.email(), password: z.string() }),\n  { out: ({ password, ...rest }) =\u003e rest },\n) {}\n\nfunction processWebhook(payload: unknown): string {\n  return JSON.stringify(toDto(UserDto, payload));   // throws ZodDtoValidationError; `out` strips password automatically\n}\n```\n\n**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.\n\nBefore:\n\n```ts\n@Get(':id')\n@ApiResponse({ status: 200, type: UserDto })        // spec only — no runtime check\nfindOne(@Param() p: UserIdParam): UserDto {\n  return this.svc.findOne(p.id);                    // returns extra/missing/wrongly-typed fields? → sent as-is\n}\n```\n\nAfter:\n\n```ts\n@Get(':id')\n@ZodResponse()                                      // schema resolved from `: UserDto`\nfindOne(@Param() p: UserIdParam): UserDto {\n  return this.svc.findOne(p.id);                    // mismatch → ZodDtoSerializationError with `path: message` issues\n}\n```\n\n**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.\n\nBefore:\n\n```ts\n@ValidatorConstraint({ name: 'atLeastOne' })\nclass AtLeastOneConstraint implements ValidatorConstraintInterface {\n  validate(_: unknown, args: ValidationArguments) {\n    const o = args.object as ContactDto;\n    return Boolean(o.email || o.phone);\n  }\n  defaultMessage() { return 'email or phone is required'; }\n}\n\nfunction AtLeastOne(opts?: ValidationOptions) {\n  return function (target: object, propertyName: string) {\n    registerDecorator({ target: target.constructor, propertyName, options: opts, validator: AtLeastOneConstraint });\n  };\n}\n\nclass ContactDto {\n  @ApiProperty({ format: 'email', required: false })\n  @IsOptional()\n  @IsEmail()\n  @AtLeastOne()                                    // attaches to one field, but `validate` reads the whole object\n  email?: string;\n\n  @ApiProperty({ pattern: '^\\\\+\\\\d{6,}$', required: false })\n  @IsOptional()\n  @IsString()\n  phone?: string;\n}\n```\n\nAfter:\n\n```ts\nclass ContactDto extends ZodDto(\n  z\n    .object({\n      email: z.email().optional(),\n      phone: z.string().regex(/^\\+\\d{6,}$/).optional(),\n    })\n    .refine((d) =\u003e d.email || d.phone, { message: 'email or phone is required' }),\n) {}\n```\n\n## Packages\n\n| Package                                               | Description                                                                                          |\n| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| [`@voznov/zod-dto`](packages/core/README.md)          | Framework-agnostic core — `ZodDto`, `toDto`, `lazyDto`, `registerOnCreate`.                          |\n| [`@voznov/zod-dto-nestjs`](packages/nestjs/README.md) | NestJS adapter — `ZodValidationPipe`, `@ZodSerialize`, `@ZodResponse`, automatic Swagger generation. |\n\n## Example\n\nA 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 \u0026\u0026 pnpm dev` and `http://localhost:3000/docs`.\n\n## Why this over alternatives\n\n- **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.\n- **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`.\n- **Real compile-time return-type check.** `@ZodResponse(NoteDto)` constrains the method's return type via TypeScript overloads — `Promise\u003cNoteDto[]\u003e` mismatches surface as `tsc` errors, not 500s in production. `nestjs-zod`'s TS-trick equivalent is brittle around `Promise` / arrays / unions.\n- **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.\n- **Recursive schemas without the type-level dance.** `lazyDto\u003cT\u003e(() =\u003e 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.\n- **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.\n- **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.\n- **Gradual migration.** `ZodValidationPipe` engages only on `ZodDto` parameters; class-validator DTOs in the same project keep working. Convert one DTO at a time.\n\n## Scripts\n\n```\npnpm build       # build both packages (tsup: ESM + CJS + dts)\npnpm test        # run vitest across the workspace\npnpm typecheck   # tsc --noEmit per package\npnpm lint        # eslint\npnpm check:all   # typecheck + lint + test\n```\n\n## Contributing\n\nIssues and PRs welcome.\n\n## License\n\n[Apache-2.0](./LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvoznov%2Fzod-dto","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvoznov%2Fzod-dto","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvoznov%2Fzod-dto/lists"}