Skip to main content

API Validation: Zod-First Pattern

The API uses Zod as the single source of truth for request shapes: runtime validator, TypeScript type, and OpenAPI schema all flow from one z.object({...}). This doc describes the pattern and how to add a new DTO.

Status: epic complete (Sprint 10). class-validator and class-transformer are no longer dependencies; every DTO is a Zod schema + createZodDto(...) class. Global validation is a pure ZodValidationPipe.

Why

  • One source of truth: a z.object({...}) produces the runtime validator, the TypeScript type, and the OpenAPI schema. No more triplicate (class-validator decorators + a TS interface + @ApiProperty(...)).
  • No reflection magic / reflect-metadata rewriting per field. Zod schemas are plain values, easier to compose, share, and test.
  • Better OpenAPI fidelity: discriminated unions, refinements, conditional shapes, etc. all serialize cleanly through z.toJSONSchema.
  • Future-proof: class-validator has effectively stalled on TC39 decorators and Node 24's native types story.

Architecture: single Zod pipe

ZodValidationPipe from nestjs-zod is registered as the only global pipe in both apps/api/src/main.ts (runtime) and apps/api/src/test/e2e-setup.ts (e2e). Every @Body and @Query parameter whose type is a createZodDto(...) class goes through it. The previous HybridValidationPipe shim is gone.

The DTO recipe

import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';

export const UNLINKABLE_PROVIDERS = ['discord', 'battlenet'] as const;
export type UnlinkableProvider = (typeof UNLINKABLE_PROVIDERS)[number];

export const UnlinkAccountSchema = z
.object({
providerId: z.enum(UNLINKABLE_PROVIDERS),
})
.strict();

export class UnlinkAccountDto extends createZodDto(UnlinkAccountSchema) {}

Rules of thumb

  1. Always .strict() on request bodies. This preserves the current forbidNonWhitelisted: true behaviour — unknown properties are rejected with a 400 rather than silently stripped.
  2. Export the schema separately from the DTO class. Schemas can be reused (e.g. for service-layer guards, tests, fixtures, frontend type generation) while the class is the Nest-specific entry point.
  3. Keep the Schema / Dto suffix convention. XxxSchema is the Zod schema, XxxDto is the class.
  4. Re-export auxiliary types the service layer relies on (UnlinkableProvider in the example above). The as const array → z.enum(...) pattern keeps one source of truth.
  5. Don't re-add @ApiProperty(...) to the class. The OpenAPI schema is generated from the Zod schema automatically via cleanupOpenApiDoc().

Shared primitives — common/schemas/

Reusable Zod primitives live in apps/api/src/common/schemas/. Every DTO migration consumes these so the same validation rules apply everywhere they appear:

SchemaUse for
userIdSchemaUser IDs (Better Auth nanoid / UUID / fixture id). Imports the regex/length constants from common/validators/user-id-format.ts.
uuidSchemaGeneric UUID (replaces NestJS's ParseUUIDPipe).
paginationSchemaCursor pagination: limit (1–100, default 20) + optional cursor.
dateIsoSchema / dateIsoCoercedSchemaISO 8601 date-time strings; the coerced variant produces a JS Date.
appLocaleSchemaApp locales (en, de, es, fr, pt, zh-CN, pseudo). Must stay in lockstep with apps/web/src/locales/.
blizzardLocaleSchemaBlizzard <lang>_<REGION> locale codes.

Add new primitives as the migration uncovers them. Keep them small, single-purpose, and exported from the barrel (schemas/index.ts).

Route params — ZodParamPipe

@Body and @Query are handled globally by ZodValidationPipe, but @Param values are plain strings — NestJS never runs them through a DTO-shaped pipe. The canonical replacement for one-off ParseUUIDPipe / ParseIntPipe / custom param pipes is ZodParamPipe:

import {
UserIdParamPipe,
UuidParamPipe,
ZodParamPipe,
} from '@/common/pipes/zod-param.pipe';
import { z } from 'zod';

// Common cases — use the pre-built singletons:
@Get(':id')
getOne(@Param('id', UserIdParamPipe) id: string) { /* ... */ }

@Delete(':noteId')
delete(@Param('noteId', UuidParamPipe) noteId: string) { /* ... */ }

// One-off shapes — pass a Zod schema directly:
@Get('items/:instanceId')
getItems(
@Param('instanceId', new ZodParamPipe(z.coerce.number().int().positive()))
instanceId: number,
) { /* ... */ }

The pipe throws a BadRequestException with both a human-readable message ("<paramName> <first issue message>") and the structured Zod issue list in errors, so the response body matches every other Zod-validated input — see the shape below.

Error response shape

The global HttpExceptionFilter already produces a stable shape. With Zod, validation failures now look like:

{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{
"code": "invalid_value",
"path": ["providerId"],
"message": "Invalid option: expected one of \"discord\"|\"battlenet\""
}
],
"timestamp": "2026-05-16T...",
"path": "/api/v1/admin/users/.../unlink-account",
"method": "POST"
}

This is a slight contract change versus the previous class-validator shape, which used to populate message with a string[] of error sentences. The new shape is strictly more structured (single message string + a typed errors array). Frontend code that reads error.data.message keeps working; anything that iterated error.data.message as an array must use error.data.errors instead.

OpenAPI / Swagger

cleanupOpenApiDoc() from nestjs-zod is called after SwaggerModule.createDocument(...) in both:

  • apps/api/src/main.ts (dev Scalar UI + /api-json endpoint)
  • apps/api/src/generate-swagger.ts (static spec for Docusaurus)

Run pnpm --filter api gen:api-spec after adding or changing any DTO to refresh the checked-in docs/static/openapi.json.

Epic history

  1. Spike: Zod + nestjs-zod proof of concept — done (Sprint 10).
  2. Build shared Zod primitives + ZodParamPipe — done (Sprint 10). userId, uuid, pagination, date, locale schemas. ParseUserIdPipe retired in favour of UserIdParamPipe across admin.controller, admin-users.controller, admin-user-notes.controller, and event.controller.
  3. Freeze new class-validator DTOs — done (Sprint 10). The ESLint guardrail and its allowlist were removed in step 8 once nothing imported class-validator / class-transformer anywhere.
  4. Migrate env + auth + admin DTOs — done (Sprint 10).
  5. Migrate event + participation + org + role + membership DTOs — done (Sprint 10).
  6. Migrate remaining domain DTOs — done (Sprint 10).
  7. Migrate Discord integration + test fixture DTOs — done (Sprint 10).
  8. Consolidate param pipes, remove class-validator/transformer entirely — done (Sprint 10). HybridValidationPipe, is-user-id.decorator.ts, and the ESLint guardrail were deleted; the class-validator and class-transformer dependencies were uninstalled. apps/api/src/main.ts and apps/api/src/test/e2e-setup.ts both register ZodValidationPipe directly as the only global pipe.

Epic status: complete. Every API DTO is a Zod schema + createZodDto(...) class, route params validate through ZodParamPipe, and Swagger output is normalized by cleanupOpenApiDoc().

Kill-switch criteria (spike outcome)

The spike was gated on the following — all passed:

  • Global validation still fires on bad input (e2e: missing field, wrong enum value, extra property all return 400 with structured errors).
  • Swagger spec generates accurate schemas (enum, required, additionalProperties: false).
  • Error response shape stays compatible with the global HttpExceptionFilter (verified via apps/api/src/admin/users/zod-spike.e2e-spec.ts).
  • Existing class-validator DTOs still work alongside Zod DTOs during the migration (verified at spike time: 19 e2e tests in admin-users.e2e-spec.ts passed against the temporary HybridValidationPipe). That pipe and the class-validator branch are gone now — every DTO is Zod and the global pipe is pure ZodValidationPipe.
  • DI integration works without reflect-metadata quirks (the Zod DTO class is a normal class; @Body() dto: UnlinkAccountDto works unchanged in the controller).

Verdict: proceed with the epic.