zod-schema-patterns
Guides Zod schema-first development patterns. Activates when defining data models, creating schemas, inferring types, validating API responses, or deriving DTOs with pick/omit/partial/extend in a TypeScript project.
SKILL.md
| Name | zod-schema-patterns |
| Description | Guides Zod schema-first development patterns. Activates when defining data models, creating schemas, inferring types, validating API responses, or deriving DTOs with pick/omit/partial/extend in a TypeScript project. |
name: zod-schema-patterns description: "Guides Zod schema-first development patterns. Activates when defining data models, creating schemas, inferring types, validating API responses, or deriving DTOs with pick/omit/partial/extend in a TypeScript project."
Zod Schema Patterns
In this project, Zod schemas are the single source of truth. Types are always inferred from schemas, never hand-written.
IMPORTANT: NEVER create a TypeScript interface or type by hand for data models. Always define a Zod schema first, then infer the type with z.infer<typeof schema>.
Schema → Type Flow
schemas/task.schema.ts types/task.ts
┌──────────────────────┐ ┌─────────────────────────────┐
│ export const │ │ import type { z } from "zod"│
│ taskSchema = z.obj({│ → │ import type { taskSchema } │
│ id: z.string(), │ │ from "../schemas/..." │
│ title: z.string(),│ │ │
│ ... │ │ export type Task = │
│ }) │ │ z.infer<typeof taskSchema>│
└──────────────────────┘ └─────────────────────────────┘
Step-by-Step: Creating Schemas for a Feature
Step 1: Create the Base Schema
// src/features/tasks/schemas/task.schema.ts
import { z } from "zod";
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
status: z.enum(["pending", "in_progress", "done"]),
priority: z.enum(["low", "medium", "high"]),
assigneeId: z.string().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
Step 2: Derive DTOs from the Base
// Create input — only fields needed for creation
export const createTaskInputSchema = taskSchema.pick({
title: true,
description: true,
priority: true,
assigneeId: true,
});
// Update input — all fields optional, exclude immutable fields
export const updateTaskInputSchema = taskSchema
.omit({ id: true, createdAt: true, updatedAt: true })
.partial();
// Filter schema — all optional for query params
export const taskFiltersSchema = z.object({
status: z.enum(["pending", "in_progress", "done"]).optional(),
priority: z.enum(["low", "medium", "high"]).optional(),
search: z.string().optional(),
assigneeId: z.string().optional(),
});
Step 3: Infer Types
// src/features/tasks/types/task.ts
import type { z } from "zod";
import type {
taskSchema,
createTaskInputSchema,
updateTaskInputSchema,
taskFiltersSchema,
} from "../schemas/task.schema";
export type Task = z.infer<typeof taskSchema>;
export type CreateTaskInput = z.infer<typeof createTaskInputSchema>;
export type UpdateTaskInput = z.infer<typeof updateTaskInputSchema>;
export type TaskFilters = z.infer<typeof taskFiltersSchema>;
Step 4: Export from Barrel
// src/features/tasks/index.ts
export { taskSchema, createTaskInputSchema, updateTaskInputSchema, taskFiltersSchema } from "./schemas/task.schema";
export type { Task, CreateTaskInput, UpdateTaskInput, TaskFilters } from "./types/task";
Decision Tree: parse() vs safeParse()
Do you trust this data?
├── YES (internal function calls, already validated upstream)
│ └── Use schema.parse(data) — throws ZodError on failure
├── NO (user input, API responses, URL params, form submissions)
│ └── Use schema.safeParse(data) — returns { success, data, error }
│ ├── success === true → use result.data
│ └── success === false → handle result.error
└── Unsure?
└── Default to safeParse() — it's always safer
Rules
- One schema file per entity:
schemas/[name].schema.ts - Schema names:
camelCase+Schemasuffix →taskSchema,authUserSchema - Types MUST be inferred:
z.infer<typeof schema>, never hand-written interfaces - Derive DTOs: Use
.pick(),.omit(),.extend(),.partial()from the base schema - Validate at boundaries: API responses, form inputs, URL params
schema.parse(): For trusted data (throws on failure)schema.safeParse(): For untrusted data (returns{ success, data, error })- Enum schemas: Use
z.enum()for finite sets of values - Export everything: Schemas and types from the feature barrel
index.ts
File Organization
src/features/[feature]/
├── schemas/
│ └── [name].schema.ts # Define schemas here
└── types/
└── [name].ts # Infer types here (z.infer only)
See examples/schema-definition.ts for the base schema pattern.
See examples/type-inference.ts for the type inference pattern.
See examples/dto-derivation.ts for deriving input/update/filter schemas.
Common Patterns
Base Entity Schema
Define the full entity with all fields.
Create Input Schema
Use .pick() or .omit() to select only the fields needed for creation:
export const createTaskInputSchema = taskSchema.pick({ title: true, description: true });
Update Input Schema
Use .partial() so all fields become optional:
export const updateTaskInputSchema = taskSchema.omit({ id: true, createdAt: true }).partial();
Filter Schema
Define with all optional fields:
export const taskFiltersSchema = z.object({
status: z.enum(["pending", "in_progress", "done"]).optional(),
search: z.string().optional(),
});
Enum Schema
Use z.enum() for finite sets:
export const taskStatusSchema = z.enum(["pending", "in_progress", "done"]);
Nested Schema
Compose schemas for nested objects:
export const taskWithAssigneeSchema = taskSchema.extend({
assignee: userSchema.nullable(),
});
Filter Schemas
Features with filterable data define two schemas:
[entity]ServerFiltersSchema— params sent to API (part of query key)[entity]ClientFiltersSchema— search + sort applied client-side
Form Input Schemas
Form components use zodResolver(schema) from @hookform/resolvers/zod. Input schemas are derived from the base entity schema via .pick(), .omit(), .partial().
API Response Validation
Validate responses inside queryFn:
queryFn: async () => {
const res = await apiClient<Task[]>("/api/tasks");
return z.array(taskSchema).parse(res.data); // validates shape
},
Common Anti-Patterns
// ❌ WRONG: Hand-written interface instead of Zod schema
interface Task {
id: string;
title: string;
status: "pending" | "in_progress" | "done";
}
// ✅ CORRECT: Zod schema + inferred type
const taskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.enum(["pending", "in_progress", "done"]),
});
type Task = z.infer<typeof taskSchema>;
// ❌ WRONG: Duplicating fields in a create schema
export const createTaskInputSchema = z.object({
title: z.string().min(1), // duplicated from taskSchema
description: z.string(), // duplicated from taskSchema
});
// ✅ CORRECT: Derive from base schema
export const createTaskInputSchema = taskSchema.pick({
title: true,
description: true,
});
// ❌ WRONG: Using `any` for untyped data
const data: any = await fetch("/api/tasks").then(r => r.json());
// ✅ CORRECT: Validate with Zod
const result = z.array(taskSchema).safeParse(await fetch("/api/tasks").then(r => r.json()));
if (result.success) {
const data = result.data; // typed as Task[]
}
// ❌ WRONG: schema.parse() on untrusted external data without error handling
const task = taskSchema.parse(req.body); // throws if invalid — unhandled
// ✅ CORRECT: safeParse() with error handling
const result = taskSchema.safeParse(req.body);
if (!result.success) {
return Response.json({ data: null, error: { message: "Validation failed", code: "VALIDATION_ERROR" } }, { status: 400 });
}
const task = result.data;
DO NOT
- DO NOT hand-write TypeScript
interfaceortypefor data models — always use Zod schemas withz.infer. - DO NOT duplicate field definitions across schemas — use
.pick(),.omit(),.extend(),.partial()to derive from the base. - DO NOT use
any— validate untyped data with Zod schemas. - DO NOT use
schema.parse()on untrusted data without error handling — useschema.safeParse()instead. - DO NOT put type files in
schemas/or schema files intypes/— keep them in their respective folders. - DO NOT create schemas without exporting them from the feature barrel
index.ts. - DO NOT define enums as plain strings — use
z.enum()for type safety and validation.