2. Define schemas + DTOs
*.schemas.ts is the heart of every feature. It holds the zod schemas, the DTO classes (via nestjs-zod's createZodDto), and the extractor function.
// backend/src/tasks/tasks.schemas.ts
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { TaskRow } from 'src/db/types';
// ========================================
// Response schemas
// ========================================
export const TaskSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
userId: z.string(),
createdAt: z.string(),
});
export class TaskDto extends createZodDto(TaskSchema) {}
// List responses are { items, total }, never a bare array.
export const TaskListSchema = z.object({
items: z.array(TaskSchema),
total: z.number(),
});
export class TaskListDto extends createZodDto(TaskListSchema) {}
// Delete responses reuse the shared DeleteResultDto from
// src/common/common.schemas. Don't redeclare it per module.
// ========================================
// Request schemas
// ========================================
export const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
});
export class CreateTaskDto extends createZodDto(CreateTaskSchema) {}
export const UpdateTaskSchema = z
.object({
title: z.string().min(1).max(200).optional(),
completed: z.boolean().optional(),
})
.strict();
export class UpdateTaskDto extends createZodDto(UpdateTaskSchema) {}
// ========================================
// DTO Extractor Functions
// ========================================
export const extractTaskDtoFromRow = (row: TaskRow): TaskDto => ({
id: row.id,
title: row.title,
completed: row.completed,
userId: row.user_id,
createdAt: row.created_at.toISOString(),
});
The global ZodValidationPipe validates every @Body() input: TaskDto against the underlying schema, so bad input becomes a 400 with field-level errors without any work in the controller.