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-validatorandclass-transformerare no longer dependencies; every DTO is a Zod schema +createZodDto(...)class. Global validation is a pureZodValidationPipe.
Why
- One source of truth: a
z.object({...})produces the runtime validator, the TypeScript type, and the OpenAPI schema. No more triplicate (class-validatordecorators + a TS interface +@ApiProperty(...)). - No reflection magic /
reflect-metadatarewriting 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-validatorhas 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
- Always
.strict()on request bodies. This preserves the currentforbidNonWhitelisted: truebehaviour — unknown properties are rejected with a 400 rather than silently stripped. - 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.
- Keep the
Schema/Dtosuffix convention.XxxSchemais the Zod schema,XxxDtois the class. - Re-export auxiliary types the service layer relies on (
UnlinkableProviderin the example above). Theas constarray →z.enum(...)pattern keeps one source of truth. - Don't re-add
@ApiProperty(...)to the class. The OpenAPI schema is generated from the Zod schema automatically viacleanupOpenApiDoc().
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:
| Schema | Use for |
|---|---|
userIdSchema | User IDs (Better Auth nanoid / UUID / fixture id). Imports the regex/length constants from common/validators/user-id-format.ts. |
uuidSchema | Generic UUID (replaces NestJS's ParseUUIDPipe). |
paginationSchema | Cursor pagination: limit (1–100, default 20) + optional cursor. |
dateIsoSchema / dateIsoCoercedSchema | ISO 8601 date-time strings; the coerced variant produces a JS Date. |
appLocaleSchema | App locales (en, de, es, fr, pt, zh-CN, pseudo). Must stay in lockstep with apps/web/src/locales/. |
blizzardLocaleSchema | Blizzard <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-jsonendpoint)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
Spike: Zod + nestjs-zod proof of concept— done (Sprint 10).Build shared Zod primitives +— done (Sprint 10).ZodParamPipeuserId,uuid, pagination, date, locale schemas.ParseUserIdPiperetired in favour ofUserIdParamPipeacrossadmin.controller,admin-users.controller,admin-user-notes.controller, andevent.controller.Freeze new class-validator DTOs— done (Sprint 10). The ESLint guardrail and its allowlist were removed in step 8 once nothing importedclass-validator/class-transformeranywhere.Migrate env + auth + admin DTOs— done (Sprint 10).Migrate event + participation + org + role + membership DTOs— done (Sprint 10).Migrate remaining domain DTOs— done (Sprint 10).Migrate Discord integration + test fixture DTOs— done (Sprint 10).Consolidate param pipes, remove class-validator/transformer entirely— done (Sprint 10).HybridValidationPipe,is-user-id.decorator.ts, and the ESLint guardrail were deleted; theclass-validatorandclass-transformerdependencies were uninstalled.apps/api/src/main.tsandapps/api/src/test/e2e-setup.tsboth registerZodValidationPipedirectly 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 viaapps/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.tspassed against the temporaryHybridValidationPipe). That pipe and the class-validator branch are gone now — every DTO is Zod and the global pipe is pureZodValidationPipe. - DI integration works without
reflect-metadataquirks (the Zod DTO class is a normal class;@Body() dto: UnlinkAccountDtoworks unchanged in the controller).
Verdict: proceed with the epic.