y: z.enum(["electronics", "apparel", "home"]),
isDiscontinued: z.boolean().default(false),
lastRestocked: z.string().datetime().optional(),
});
// Automatically derive the TypeScript interface
export type InventoryItem = z.infer<typeof InventoryItemSchema>;
**Why this works:** `z.infer` extracts the exact TypeScript type from the schema. If you modify the schema, the type updates automatically. This eliminates manual interface maintenance and guarantees that compile-time types always match runtime expectations.
### Step 2: Compose Schemas for Read/Write Operations
Real-world APIs rarely use identical shapes for creation, updates, and responses. Zod provides composition utilities to derive variations without duplication.
```typescript
export const CreateItemPayload = InventoryItemSchema.omit({
sku: true,
isDiscontinued: true,
lastRestocked: true,
});
export const UpdateItemPayload = CreateItemPayload.partial().extend({
sku: z.string(), // Required for routing, but not part of the update body
});
export const ItemResponseSchema = InventoryItemSchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
Architecture rationale: Omitting and extending schemas prevents copy-paste drift. The base schema remains the canonical definition, while operation-specific schemas inherit validation rules and only adjust field requirements.
Step 3: Integrate Validation into the Request Pipeline
Validation should occur at the network boundary, before data reaches business logic. A middleware approach centralizes enforcement and standardizes error responses.
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
interface GuardConfig {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
export function requestGuard(config: GuardConfig) {
return (req: Request, res: Response, next: NextFunction) => {
const targets = [
{ key: "body", schema: config.body, source: req.body },
{ key: "query", schema: config.query, source: req.query },
{ key: "params", schema: config.params, source: req.params },
];
for (const { key, schema, source } of targets) {
if (!schema) continue;
const result = schema.safeParse(source);
if (!result.success) {
return res.status(400).json({
status: "error",
code: "VALIDATION_FAILURE",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
constraint: issue.code,
})),
});
}
// Replace raw input with validated/coerced data
(req as any)[key] = result.data;
}
next();
};
}
Why safeParse over parse: safeParse returns a discriminated union { success: boolean; data?: T; error?: ZodError }. This allows graceful handling without throwing exceptions, which is critical for middleware that must continue the request lifecycle or return structured HTTP responses.
Step 4: Handle Cross-Field Validation with Refinements
Single-field validators cover most cases, but business rules often require comparing multiple fields. Zod provides refine and superRefine for this purpose.
export const CheckoutPayloadSchema = z.object({
items: z.array(
z.object({
sku: z.string(),
quantity: z.number().int().min(1),
})
).min(1),
discountCode: z.string().optional(),
expectedTotal: z.number().positive(),
});
export const ValidatedCheckoutSchema = CheckoutPayloadSchema.superRefine((data, ctx) => {
const calculatedTotal = data.items.reduce((sum, item) => {
// In production, fetch price from DB/cache
return sum + item.quantity * 29.99;
}, 0);
if (Math.abs(calculatedTotal - data.expectedTotal) > 0.05) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Expected total mismatch. Calculated: ${calculatedTotal.toFixed(2)}`,
path: ["expectedTotal"],
});
}
});
Architecture decision: superRefine is preferred over refine when you need to attach errors to specific fields or add multiple issues. It provides direct access to the context object (ctx), enabling precise error routing without aborting the entire validation prematurely.
Pitfall Guide
1. Using parse() in Critical Request Paths
Explanation: parse() throws exceptions on failure. In Express or Fastify, uncaught exceptions bubble up to global error handlers, often resulting in generic 500 responses instead of 400 validation errors.
Fix: Always use safeParse() in middleware or API handlers. Handle the success: false branch explicitly to return structured client errors.
2. Ignoring Type Coercion for Query Parameters
Explanation: HTTP query strings are always strings. Passing ?page=2 directly to a number schema fails validation.
Fix: Use z.coerce.number() or z.coerce.boolean() for query/param schemas. This automatically converts string inputs to their target types before validation.
3. Duplicating Schemas for Similar Operations
Explanation: Copy-pasting schemas for Create, Update, and List endpoints creates maintenance debt. Changes to base rules require manual synchronization.
Fix: Define a base schema and use .omit(), .partial(), .extend(), and .pick() to derive operation-specific variants. Keep the base schema in a shared module.
4. Using refine() for Asynchronous Checks
Explanation: refine() runs synchronously. Attempting to call a database or external API inside it will either fail or block the event loop.
Fix: Zod does not support async validation natively in sync pipelines. Extract async checks to service-layer guards, or use superRefine with a pre-fetched validation map. Alternatively, validate synchronously first, then run async business rules separately.
5. Flattening Error Paths Incorrectly
Explanation: Mapping issue.path to a dot-separated string works for simple objects but breaks with nested arrays or tuples. Frontend forms expect precise field identifiers.
Fix: Preserve the path array in internal logs, but provide a formatter that converts arrays to bracket notation (items[0].sku) for frontend consumption. Never assume dot notation is universally safe.
6. Bypassing Validation in Internal Microservice Calls
Explanation: Teams often skip validation for "trusted" internal services. Over time, service contracts drift, and unvalidated payloads cause silent data corruption.
Fix: Apply the same schemas to internal RPC or message queue payloads. Use schema sharing via monorepo workspaces or published packages to enforce consistency across service boundaries.
7. Testing Only Happy Paths
Explanation: Unit tests that only pass valid data give false confidence. They miss edge cases like missing fields, type mismatches, or boundary violations.
Fix: Implement property-based testing or explicit negative test cases. Validate against malformed payloads, empty strings, null values, and out-of-range numbers. Use safeParse assertions to verify both success and failure branches.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public API with strict contracts | Zod + requestGuard middleware | Enforces exact shape, returns granular 400 errors | Low (minimal boilerplate) |
| Internal microservice communication | Shared Zod schemas + safeParse | Prevents contract drift without runtime overhead | Medium (package management) |
| Legacy codebase migration | Gradual adoption via safeParse wrappers | Avoids breaking existing untyped routes | Low (incremental rollout) |
| Complex async validation rules | Sync Zod + separate service-layer guard | Zod lacks native async; separates concerns cleanly | Medium (additional layer) |
| High-throughput endpoints | Pre-compiled schemas + parse() in hot path | parse() is faster than safeParse when failure is unacceptable | Low (micro-optimization) |
Configuration Template
// src/validation/guard.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
export type ValidationTarget = "body" | "query" | "params";
export interface GuardOptions {
targets: Array<{
key: ValidationTarget;
schema: ZodSchema;
strict?: boolean; // If true, uses parse() instead of safeParse()
}>;
}
export function createGuard(options: GuardOptions) {
return (req: Request, res: Response, next: NextFunction) => {
for (const { key, schema, strict } of options.targets) {
const source = req[key];
if (!source && !strict) continue;
try {
const validated = strict ? schema.parse(source) : schema.safeParse(source);
if (!strict && !validated.success) {
return res.status(400).json({
status: "validation_error",
issues: validated.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
rule: i.code,
})),
});
}
if (strict) {
(req as any)[key] = validated;
} else {
(req as any)[key] = validated.data;
}
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
status: "validation_error",
issues: err.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
rule: i.code,
})),
});
}
return next(err);
}
}
next();
};
}
Quick Start Guide
- Install dependencies: Run
npm install zod and ensure TypeScript is configured with strict: true.
- Create a base schema: Define your first data contract using
z.object() and export the inferred type via z.infer.
- Add middleware: Import
createGuard (or the simpler requestGuard from earlier) and attach it to your route definitions before business logic handlers.
- Test validation: Send malformed payloads to your endpoints. Verify that the API returns
400 with structured issues arrays instead of crashing or returning 500.
- Share schemas: Move schema definitions to a shared package or monorepo workspace. Import them in frontend form libraries (React Hook Form, Zod resolver) to guarantee end-to-end type consistency.