
Zod V4
- 13 installs
- 5 repo stars
- Updated August 5, 2026
- bjornmelin/dev-skills
Zod v4 is a Claude Code skill giving expert guidance for Zod v4 schema validation in TypeScript, including migration from Zod 3 and JSON Schema/OpenAPI generation.
About
Zod v4 provides guidance for schema validation in TypeScript using Zod v4. It covers designing schemas, migrating from Zod 3, handling validation errors, and generating JSON Schema or OpenAPI. A developer uses it when defining data contracts or integrating validation with React Hook Form, tRPC, Hono, or Next.js. It documents v4 APIs such as top-level string formats, strictObject/looseObject, registries, branded types, and recursive schemas.
- Expert guidance for Zod v4 schema design and migration from Zod 3
- Covers top-level string formats, strictObject/looseObject, branded and recursive schemas, and codecs/transforms
- Integrates with React Hook Form, tRPC, Hono, and Next.js and generates JSON Schema/OpenAPI
Zod V4 by the numbers
- 13 all-time installs (skills.sh)
- Ranked #3,516 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
zod-v4 capabilities & compatibility
- Capabilities
- schema validation · type inference · json schema generation · error handling
- Use cases
- api development · refactoring
- IDEs
- vscode · cursor ide
What zod-v4 says it does
v4 moved string validators to top-level functions:
z.object({}) // Allows unknown keys (default)
// Generate JSON Schema
npx skills add https://github.com/bjornmelin/dev-skills --skill zod-v4Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | bjornmelin/dev-skills ↗ |
What it does
Design and migrate Zod v4 schemas in TypeScript, handle validation errors, and generate JSON Schema/OpenAPI.
Who is it for?
Designing and migrating Zod v4 validation schemas and error handling in TypeScript apps.
Skip if: Non-TypeScript validation or runtimes without Zod.
When should I use this skill?
Designing schemas, migrating from Zod 3, handling validation errors, generating JSON Schema/OpenAPI, or integrating with RHF/tRPC/Hono/Next.js.
What you get
Correct, idiomatic Zod v4 schemas with proper error handling and generated JSON Schema/OpenAPI.
- Zod v4 schemas
- validation error handling
- JSON Schema/OpenAPI output
By the numbers
- 4 bundled reference guides
- 10-plus row v3-to-v4 migration table
Files
Zod v4 Schema Validation
Quick Start
pnpm add zod@^4.3.5import { z } from 'zod';
// Define schema
const User = z.object({
name: z.string().min(1),
email: z.email(),
age: z.number().positive(),
});
// Parse (throws on error)
const user = User.parse({ name: "Alice", email: "alice@example.com", age: 30 });
// Safe parse (returns result)
const result = User.safeParse(data);
if (result.success) {
result.data; // validated
} else {
console.log(z.prettifyError(result.error));
}
// Type inference
type User = z.infer<typeof User>;Versioning + Imports (v4.3.5)
- Use
import { z } from "zod"for v4 (package root now exports v4). - Use
import * as z from "zod/mini"for Zod Mini. - Use
import * as z from "zod/v3"only if you must stay on v3.
Workflow: Determine Task Type
Designing new schemas? → Read API Reference
Migrating from Zod 3? → Read Migration Guide
Working with codecs, errors, JSON Schema, or metadata? → Read Advanced Features
Integrating with frameworks (RHF, tRPC, Hono, Next.js)? → Read Ecosystem Patterns
---
Key v4 Concepts
Top-Level String Formats
v4 moved string validators to top-level functions:
// v4 style (preferred)
z.email()
z.uuid()
z.url()
z.ipv4()
z.ipv6()
z.iso.date()
z.iso.datetime()
// v3 style (deprecated but works)
z.string().email()Object Variants
z.object({}) // Allows unknown keys (default)
z.strictObject({}) // Rejects unknown keys
z.looseObject({}) // Explicitly allows unknown keysUnified Error Parameter
// String message
z.string().min(5, { error: "Too short" });
// Function for dynamic messages
z.string({
error: (iss) => iss.input === undefined ? "Required" : "Invalid"
});Type Inference
const Schema = z.object({ name: z.string() });
type Schema = z.infer<typeof Schema>;
// For transforms, get input/output separately
const Transformed = z.string().transform(s => s.length);
type Input = z.input<typeof Transformed>; // string
type Output = z.output<typeof Transformed>; // number---
Common Patterns
Discriminated Unions
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
]);Exhaustive Records
const Status = z.enum(["pending", "active", "done"]);
// All keys required
z.record(Status, z.number()) // { pending: number; active: number; done: number }
// Keys optional
z.partialRecord(Status, z.number()) // { pending?: number; active?: number; done?: number }Recursive Schemas
const Category = z.object({
name: z.string(),
get subcategories() { return z.array(Category) }
});Branded Types
const UserId = z.string().brand<"UserId">();
const PostId = z.string().brand<"PostId">();
type UserId = z.infer<typeof UserId>;
// Cannot assign UserId to PostIdTransforms and Pipes
// Transform
z.string().transform(s => s.toUpperCase())
// Pipe (chain schemas)
z.pipe(
z.string(),
z.coerce.number(),
z.number().positive()
)Default Values
// Output default (v4)
z.string().default("guest")
// Input default (pre-transform)
z.string().transform(s => s.toUpperCase()).prefault("hello")
// Missing => "HELLO"---
Error Handling
Pretty Print
const result = schema.safeParse(data);
if (!result.success) {
console.log(z.prettifyError(result.error));
// ✖ Invalid email
// → at email
}Flat Structure (Forms)
const flat = z.flattenError(result.error);
// { formErrors: [], fieldErrors: { email: ["Invalid email"] } }Tree Structure (Nested)
const tree = z.treeifyError(result.error);
// { properties: { email: { errors: ["Invalid email"] } } }---
JSON Schema / OpenAPI
const schema = z.object({
name: z.string(),
email: z.email(),
}).meta({ id: "User", title: "User" });
// Generate JSON Schema
const jsonSchema = z.toJSONSchema(schema);
// For OpenAPI 3.0
z.toJSONSchema(schema, { target: "openapi-3.0" });
// Using registry for multiple schemas
z.globalRegistry.add(schema, schema.meta());
const allSchemas = z.toJSONSchema(z.globalRegistry);---
v3 to v4 Migration Quick Reference
| v3 | v4 |
|---|---|
z.string().email() | z.email() |
z.nativeEnum(MyEnum) | z.enum(MyEnum) |
{ message: "..." } | { error: "..." } |
.strict() | z.strictObject({}) |
.passthrough() | z.looseObject({}) |
.merge(other) | .extend(other.shape) |
z.record(valueSchema) | z.record(z.string(), valueSchema) |
.deepPartial() | Nest .partial() manually |
error.format() | z.treeifyError(error) |
error.flatten() | z.flattenError(error) |
Breaking Changes
- Numbers: No
Infinity, stricter.safe()and.int() - UUID: RFC 4122 compliant (use
z.guid()for permissive) - Defaults in optional:
z.string().default("x").optional()now applies default - z.unknown(): No longer implicitly optional
- Error precedence: Schema-level wins over global
Run codemod: npx zod-v3-to-v4
---
Framework Integration Quick Start
React Hook Form
import { zodResolver } from '@hookform/resolvers/zod';
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});tRPC
publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => getById(input.id))Hono
import { zValidator } from '@hono/zod-validator';
app.post('/users', zValidator('json', schema), (c) => {
const data = c.req.valid('json');
});Next.js Server Actions
'use server';
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { errors: z.flattenError(result.error).fieldErrors };
}---
Reference Files
- API Reference - All schema types, methods, and validation APIs
- Advanced Features - Codecs, error handling, metadata, JSON Schema
- Migration Guide - Complete v3 to v4 migration reference
- Ecosystem Patterns - Framework integrations and organization patterns
Zod v4 Advanced Features
Codecs (Bidirectional Transformations)
Codecs enable type-safe encode/decode operations for network boundaries, database serialization, and API transformations.
Core Operations
// Forward: Input -> Output
schema.parse(data) // Accepts unknown
schema.decode(data) // Type-safe input
// Backward: Output -> Input
schema.encode(data) // Type-safe reverseDefining Codecs
const stringToDate = z.codec(
z.iso.datetime(), // input schema
z.date(), // output schema
{
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
}
);
stringToDate.decode("2024-01-01T00:00:00Z"); // Date
stringToDate.encode(new Date()); // ISO stringBuilt-in Codecs
// String to number
z.coerce.number()
// ISO datetime to Date
z.pipe(z.iso.datetime(), z.transform(v => new Date(v)))
// JSON codec pattern
const jsonCodec = <T extends z.ZodType>(schema: T) =>
z.codec(z.string(), schema, {
decode: (str, ctx) => {
try { return JSON.parse(str); }
catch (err) {
ctx.issues.push({ code: "invalid_format", format: "json", input: str, message: err.message });
return z.NEVER;
}
},
encode: (value) => JSON.stringify(value),
});Async Support
await schema.decodeAsync(data);
await schema.encodeAsync(data);
const result = await schema.safeDecodeAsync(data);Encoding Rules
- Refinements (
.refine(),.min(),.max()) run in both directions - Mutating transforms (
.trim(),.toLowerCase()) work bidirectionally .default()and.catch()only apply during decode.transform()is unidirectional (throws on encode)
---
Error Customization
Unified Error Parameter (v4)
// String message
z.string("Not a string!");
z.string().min(5, "Too short!");
z.email("Invalid email");
// Error map function
z.string({
error: (iss) => iss.input === undefined
? "Field is required."
: "Invalid input."
});
// Available context in error function
// - code: Issue code
// - input: Input data
// - inst: Schema instance
// - path: Error path
// - Method-specific: minimum, maximum, inclusive, etc.Precedence (Highest to Lowest)
1. Schema-level error parameter 2. Per-parse error maps 3. Global error configuration 4. Locale error maps
Per-Parse Customization
schema.parse(data, {
error: (iss) => "Custom error for this parse"
});Global Configuration
z.config({
customError: (iss) => {
if (iss.code === "invalid_type") {
return `Invalid type, expected ${iss.expected}`;
}
// Return undefined to defer to next level
},
});Internationalization
import { en, es, fr } from "zod/locales";
z.config(en()); // English (default)
z.config(es()); // Spanish
z.config(fr()); // French
// Dynamic loading
const locale = await import(`zod/v4/locales/${lang}.js`);
z.config(locale.default());Available: 40+ languages including ar, de, es, fr, ja, ko, pt, ru, zh.
Including Input in Errors
// Disabled by default (security)
z.string().parse(12, { reportInput: true });---
Error Formatting
z.prettifyError()
Human-readable string for CLI/logging:
const result = schema.safeParse(data);
if (!result.success) {
console.log(z.prettifyError(result.error));
}
// Output:
// ✖ Invalid type, expected string
// → at username
// ✖ Too short
// → at passwordz.treeifyError()
Nested object structure mirroring schema:
const tree = z.treeifyError(error);
// {
// errors: string[],
// properties: { username: { errors: [...] } },
// items: [undefined, { errors: [...] }]
// }
const usernameErrors = tree.properties?.username?.errors;z.flattenError()
Flat structure for forms:
const flat = z.flattenError(error);
// {
// formErrors: string[], // Top-level errors
// fieldErrors: {
// username: string[],
// password: string[]
// }
// }---
Metadata and Registries
Using .meta()
const emailSchema = z.email().meta({
id: "email_address",
title: "Email Address",
description: "User's email",
examples: ["user@example.com"],
deprecated: false
});
// Retrieve metadata
emailSchema.meta(); // { id: "...", title: "...", ... }
// Shorthand for description only
emailSchema.describe("An email address");Global Registry
// Register schemas
z.globalRegistry.add(UserSchema, { id: "User", title: "User" });
z.globalRegistry.add(PostSchema, { id: "Post", title: "Post" });
// Check registration
z.globalRegistry.has(UserSchema); // true
// Convert all to JSON Schema
const schemas = z.toJSONSchema(z.globalRegistry);Custom Registries
const myRegistry = z.registry<{ description: string }>();
myRegistry.add(z.string(), { description: "A string" });
// Type-constrained registry
const stringRegistry = z.registry<{ desc: string }, z.ZodString>();.register() Method
Returns original instance (unique among methods):
schema.register(myRegistry, { description: "..." });
// => schema (not a new instance)---
JSON Schema Conversion
Basic Usage
const jsonSchema = z.toJSONSchema(schema);Configuration
z.toJSONSchema(schema, {
target: "draft-2020-12", // draft-4, draft-7, draft-2020-12, openapi-3.0
metadata: z.globalRegistry, // Include metadata
unrepresentable: "throw", // "throw" | "any"
cycles: "ref", // "ref" | "throw"
reused: "inline", // "inline" | "ref"
io: "output", // "output" | "input"
});OpenAPI Target
z.toJSONSchema(schema, { target: "openapi-3.0" });Handling Unrepresentable Types
Non-representable: z.bigint(), z.date(), z.map(), z.set(), z.transform(), z.custom(), z.lazy(), z.promise()
z.toJSONSchema(schema, {
unrepresentable: "any", // Convert to {}
override: (ctx) => {
if (ctx.schema._def.typeName === "ZodDate") {
return { type: "string", format: "date-time" };
}
}
});File Schemas in JSON Schema
const fileSchema = z.file().min(1024).max(5242880).mime(["image/png"]);
// Converts to:
// {
// "type": "string",
// "format": "binary",
// "contentEncoding": "binary",
// "contentMediaType": "image/png",
// "minLength": 1024,
// "maxLength": 5242880
// }Registry-Based Conversion
z.globalRegistry.add(UserSchema, { id: "User" });
z.globalRegistry.add(PostSchema, { id: "Post" });
const allSchemas = z.toJSONSchema(z.globalRegistry);
// Returns interconnected schemas with $ref pointers---
Branded Types
Nominal typing for type-safe domain modeling:
const UserId = z.string().brand<"UserId">();
const PostId = z.string().brand<"PostId">();
type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;
const userId: UserId = UserId.parse("user-123");
const postId: PostId = PostId.parse("post-456");
// TypeScript error: types not assignable
const wrong: UserId = postId;Common Use Cases
// IDs
const Email = z.email().brand<"Email">();
const Currency = z.number().positive().brand<"Currency">();
const SanitizedHtml = z.string().brand<"SanitizedHtml">();
// Validated domain values
const ValidatedUrl = z.url().brand<"ValidatedUrl">();---
Recursive Schemas (v4)
No type casting required:
const Category = z.object({
name: z.string(),
get subcategories() { return z.array(Category) }
});
type Category = z.infer<typeof Category>;
// { name: string; subcategories: Category[] }Mutually Recursive
const Node = z.object({
value: z.string(),
get children() { return z.array(Edge) }
});
const Edge = z.object({
label: z.string(),
get target() { return Node }
});---
File Schemas
z.file()
z.file().min(1024) // Min bytes
z.file().max(1024 * 1024) // Max bytes
z.file().mime(["image/png", "image/jpeg"])
// Combined
const imageSchema = z.file()
.max(5 * 1024 * 1024)
.mime(["image/png", "image/jpeg", "image/webp"]);---
Template Literals
Pattern-based string validation:
z.templateLiteral([z.string(), "-", z.number()])
// Matches: "abc-123", "test-456"
z.templateLiteral(["user_", z.string()])
// Matches: "user_alice", "user_bob"---
Overwrite Method (v4)
Transform without changing type (stored as refinement):
const schema = z.string().overwrite(s => s.trim());
type T = z.infer<typeof schema>; // string (not affected)Zod v4 API Reference
Primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()Coercion
z.coerce.string() // "42" from 42
z.coerce.number() // 42 from "42"
z.coerce.boolean()
z.coerce.bigint()
z.coerce.date()String Formats (Top-Level in v4)
// Email
z.email()
z.email({ error: "Invalid email" })
// UUIDs
z.uuid() // Any version
z.uuidv4()
z.uuidv6()
z.uuidv7()
z.guid() // Permissive matching
// URLs
z.url()
z.url({ protocols: ["https"] })
// Date/Time (ISO formats)
z.iso.datetime()
z.iso.datetime({ offset: true })
z.iso.datetime({ precision: 3 })
z.iso.date() // YYYY-MM-DD
z.iso.time() // HH:MM:SS
// Network
z.ipv4()
z.ipv6()
z.cidrv4()
z.cidrv6()
z.mac()
// Other
z.jwt()
z.hash("sha256")
z.base64()
z.base64url()String Validations
z.string().min(5)
z.string().max(10)
z.string().length(8)
z.string().regex(/^\d+$/)
z.string().startsWith("pre")
z.string().endsWith("fix")
z.string().includes("mid")
z.string().uppercase()
z.string().lowercase()
// Transforms
z.string().toLowerCase()
z.string().toUpperCase()
z.string().trim()Numbers
z.number()
z.number().gt(5)
z.number().gte(5)
z.number().lt(10)
z.number().lte(10)
z.number().positive()
z.number().negative()
z.number().multipleOf(5)
z.number().finite()
z.number().safe()
// Integers
z.int() // Safe integer
z.int32() // 32-bit signedObjects
// Standard (allows unknown keys by default)
z.object({ name: z.string(), age: z.number() })
// Strict (rejects unknown keys)
z.strictObject({ name: z.string() })
// Loose (explicitly allows unknown keys)
z.looseObject({ name: z.string() })Object Methods
const User = z.object({ name: z.string(), age: z.number() });
User.shape.name // Access field schema
User.keyof() // z.enum(["name", "age"])
User.extend({ email: z.string() })
User.safeExtend({ email: z.string() }) // preserves refinements
User.pick({ name: true })
User.omit({ age: true })
User.partial() // All optional
User.required() // All required
User.catchall(z.string()) // Unknown keys must be stringsArrays and Tuples
// Arrays
z.array(z.string())
z.array(z.number()).min(1)
z.array(z.number()).max(10)
z.array(z.number()).length(5)
z.array(z.number()).nonempty()
// Tuples
z.tuple([z.string(), z.number()])
z.tuple([z.string()], z.number()) // Variadic: [string, ...number[]]Unions and Intersections
// Union (OR)
z.union([z.string(), z.number()])
// Discriminated Union (efficient)
z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.string() }),
z.object({ status: z.literal("error"), error: z.string() })
])
// Intersection (AND)
z.intersection(
z.object({ name: z.string() }),
z.object({ age: z.number() })
)Enums
// Array enum
const Role = z.enum(["admin", "user", "guest"]);
Role.exclude(["guest"])
Role.extract(["admin"])
// TypeScript enum (v4 - use z.enum directly)
enum Color { Red, Green, Blue }
z.enum(Color) // Not z.nativeEnum() in v4Records, Maps, Sets
// Record (always specify key schema in v4)
z.record(z.string(), z.number())
z.record(z.enum(["a", "b"]), z.number()) // Exhaustive
z.partialRecord(z.enum(["a", "b"]), z.number()) // Optional keys
// Maps and Sets
z.map(z.string(), z.number())
z.set(z.string())
z.set(z.number()).min(2).max(10)Refinements
// Basic refinement
z.string().refine(val => val.length >= 5, {
error: "Must be at least 5 characters"
})
// With path for objects
z.object({ start: z.date(), end: z.date() })
.refine(data => data.end > data.start, {
error: "End must be after start",
path: ["end"]
})
// superRefine for multiple issues
z.string().superRefine((val, ctx) => {
if (val.length < 5) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 5,
type: "string",
inclusive: true,
error: "Too short"
});
}
})
// Simple check
z.string().check(val => val.length >= 5)Transforms
// Transform
z.string().transform(val => val.length)
z.string().transform(val => val.toUpperCase())
// Pipe (chain schemas)
z.pipe(
z.string(),
z.coerce.number(),
z.number().positive()
)
// Preprocess
z.preprocess(
val => String(val).trim(),
z.string().min(1)
)Defaults and Catch
// Default (for missing/undefined)
z.string().default("guest")
z.number().default(() => Date.now())
// Prefault (parse default as input - v4)
z.string().transform(s => s.toUpperCase()).prefault("hello")
// Missing input => "HELLO"
// Catch (fallback on error)
z.number().catch(0)Type Inference
const User = z.object({ name: z.string() });
// Infer output type
type User = z.infer<typeof User>;
// For transforms, get input/output separately
const schema = z.string().transform(val => val.length);
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // numberParsing
// Throws on failure
const data = schema.parse(input);
await schema.parseAsync(input);
// Safe (returns result object)
const result = schema.safeParse(input);
if (result.success) {
result.data; // Validated
} else {
result.error; // ZodError
}
await schema.safeParseAsync(input);Advanced Types
// Literals (multiple values in v4)
z.literal("admin")
z.literal([200, 201, 204])
// Template literals
z.templateLiteral([z.string(), "-", z.number()])
// Stringbool
z.stringbool() // "true"/"false" => boolean
// Files
z.file()
z.file().min(1024).max(1024 * 1024)
z.file().mime(["image/png"])
// Branded types
const UserId = z.string().brand<"UserId">();
// Readonly
z.object({ name: z.string() }).readonly()
// JSON
z.json()
// Custom
z.custom<MyType>(val => val instanceof MyType)
// Recursive
const Category = z.object({
name: z.string(),
get subcategories() { return z.array(Category) }
});
// Functions
z.function({
input: [z.string(), z.number()],
output: z.boolean()
}).implement((str, num) => str.length > num)Zod v4 Ecosystem Integration Patterns
React Hook Form
Basic Integration
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, { error: 'Required' }),
email: z.email({ error: 'Invalid email' }),
age: z.number().min(18),
});
type FormData = z.infer<typeof schema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Submit</button>
</form>
);
}Best Practices
- Keep schemas in dedicated
src/schemas/folder for reuse - Use
valueAsNumberfor number inputs - Share schemas between client and server validation
Compatibility Note
If a dependency is still pinned to Zod v3, use an explicit v3 import:
import * as z from 'zod/v3';Prefer import { z } from 'zod' for v4 unless you must stay on v3.
---
tRPC
Router Definition
import { z } from 'zod';
import { router, publicProcedure } from './trpc';
const userSchema = z.object({
name: z.string().min(1),
email: z.email(),
});
export const appRouter = router({
createUser: publicProcedure
.input(userSchema)
.mutation(({ input }) => {
// input is fully typed as { name: string; email: string }
return createUser(input);
}),
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return getUserById(input.id);
}),
listUsers: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
}))
.query(({ input }) => {
return listUsers(input.limit, input.cursor);
}),
});Output Validation
const userOutput = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
});
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.output(userOutput)
.query(({ input }) => {
return getUserById(input.id);
}),
});---
Hono
Validation Middleware
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
const createUserSchema = z.object({
name: z.string().min(1),
email: z.email(),
});
app.post(
'/users',
zValidator('json', createUserSchema),
(c) => {
const data = c.req.valid('json');
// data is typed as { name: string; email: string }
return c.json({ success: true, user: data });
}
);
// Query params
const listSchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().max(100).default(20),
});
app.get(
'/users',
zValidator('query', listSchema),
(c) => {
const { page, limit } = c.req.valid('query');
return c.json({ page, limit });
}
);
// URL params
app.get(
'/users/:id',
zValidator('param', z.object({ id: z.string() })),
(c) => {
const { id } = c.req.valid('param');
return c.json({ id });
}
);Custom Error Handling
app.post(
'/users',
zValidator('json', schema, (result, c) => {
if (!result.success) {
return c.json({
error: 'Validation failed',
issues: z.flattenError(result.error),
}, 400);
}
}),
handler
);---
Next.js Server Actions
Basic Pattern
'use server';
import { z } from 'zod';
const loginSchema = z.object({
email: z.email(),
password: z.string().min(8),
});
export async function loginAction(prevState: any, formData: FormData) {
const result = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!result.success) {
return {
success: false,
errors: z.flattenError(result.error).fieldErrors,
};
}
// Process validated data
const { email, password } = result.data;
// ... authenticate user
return { success: true };
}With useActionState
'use client';
import { useActionState } from 'react';
import { loginAction } from './actions';
export function LoginForm() {
const [state, formAction, isPending] = useActionState(loginAction, null);
return (
<form action={formAction}>
<input name="email" type="email" />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<input name="password" type="password" />
{state?.errors?.password && <span>{state.errors.password[0]}</span>}
<button disabled={isPending}>
{isPending ? 'Loading...' : 'Login'}
</button>
</form>
);
}ZSA (Zod Server Actions)
import { createServerAction } from 'zsa';
const createUser = createServerAction()
.input(z.object({
name: z.string(),
email: z.email(),
}))
.output(z.object({
id: z.string(),
name: z.string(),
}))
.handler(async ({ input }) => {
const user = await db.users.create({ data: input });
return user;
});---
API Routes (Generic Pattern)
Request Validation
import { z } from 'zod';
const requestSchema = z.object({
body: z.object({
name: z.string(),
email: z.email(),
}),
query: z.object({
include: z.enum(['posts', 'comments']).optional(),
}),
params: z.object({
id: z.string(),
}),
});
export async function handler(req: Request) {
const result = requestSchema.safeParse({
body: await req.json(),
query: Object.fromEntries(new URL(req.url).searchParams),
params: extractParams(req),
});
if (!result.success) {
return Response.json(
{ error: z.prettifyError(result.error) },
{ status: 400 }
);
}
const { body, query, params } = result.data;
// ... handle request
}Response Validation
const responseSchema = z.object({
success: z.boolean(),
data: z.object({
id: z.string(),
name: z.string(),
}).optional(),
error: z.string().optional(),
});
function createResponse(data: unknown) {
const result = responseSchema.safeParse(data);
if (!result.success) {
console.error('Invalid response shape:', z.prettifyError(result.error));
return Response.json({ success: false, error: 'Internal error' }, { status: 500 });
}
return Response.json(result.data);
}---
OpenAPI/JSON Schema
Generating OpenAPI Spec
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
}).meta({
id: 'User',
title: 'User',
description: 'A user in the system',
});
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.email(),
}).meta({
id: 'CreateUser',
title: 'Create User Request',
});
// Register for JSON Schema generation
z.globalRegistry.add(UserSchema, UserSchema.meta());
z.globalRegistry.add(CreateUserSchema, CreateUserSchema.meta());
// Generate all schemas
const schemas = z.toJSONSchema(z.globalRegistry, {
target: 'openapi-3.0',
});With zod-to-openapi
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
const registry = new OpenAPIRegistry();
registry.registerPath({
method: 'post',
path: '/users',
request: { body: { content: { 'application/json': { schema: CreateUserSchema } } } },
responses: {
200: { content: { 'application/json': { schema: UserSchema } } },
},
});
const generator = new OpenApiGeneratorV3(registry.definitions);
const openApiDocument = generator.generateDocument({
info: { title: 'My API', version: '1.0.0' },
});---
Schema Organization Patterns
Domain-Based Structure
src/
schemas/
user.ts # User-related schemas
post.ts # Post-related schemas
common.ts # Shared schemas (pagination, errors)
index.ts # Re-exportsExample: user.ts
import { z } from 'zod';
// Base schemas
export const UserIdSchema = z.string().brand<'UserId'>();
export const EmailSchema = z.email().brand<'Email'>();
// Domain schemas
export const UserSchema = z.object({
id: UserIdSchema,
name: z.string().min(1).max(100),
email: EmailSchema,
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.date(),
});
// Input schemas
export const CreateUserSchema = UserSchema.pick({
name: true,
email: true,
role: true,
});
export const UpdateUserSchema = CreateUserSchema.partial();
// Query schemas
export const UserQuerySchema = z.object({
role: z.enum(['admin', 'user', 'guest']).optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
});
// Types
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
export type UserQuery = z.infer<typeof UserQuerySchema>;Example: common.ts
import { z } from 'zod';
// Pagination
export const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
});
export const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number(),
page: z.number(),
limit: z.number(),
hasMore: z.boolean(),
});
// Error responses
export const ApiErrorSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.array(z.string())).optional(),
}),
});
// Success responses
export const ApiSuccessSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
});---
Performance Tips
Avoid Deep Chaining
// Slower: many .extend()/.pick() calls
const schema = base.extend({...}).pick({...}).extend({...});
// Faster: compose smaller schemas
const NameSchema = z.object({ name: z.string() });
const EmailSchema = z.object({ email: z.email() });
const schema = z.object({ ...NameSchema.shape, ...EmailSchema.shape });Use Zod Mini for Edge/Serverless
import { z } from 'zod/mini'; // ~1.9kb gzippedMemoize Repeated Validations
const cache = new Map<string, unknown>();
function validateCached<T>(schema: z.ZodType<T>, input: unknown, key: string): T {
if (cache.has(key)) return cache.get(key) as T;
const result = schema.parse(input);
cache.set(key, result);
return result;
}Zod v3 to v4 Migration Guide
Error Customization (Breaking)
Unified error Parameter
// v3
z.string().min(5, { message: "Too short" });
z.string({ invalid_type_error: "Not a string", required_error: "Required" });
// v4
z.string().min(5, { error: "Too short" });
z.string({ error: (iss) => iss.input === undefined ? "Required" : "Not a string" });Error Map Precedence Reversed
v4: Schema-level > per-parse > global > locale (schema wins) v3: Global > schema-level (global won)
Deprecated Error Methods
// v3
error.format() // Deprecated
error.flatten() // Deprecated
error.formErrors // Dropped
error.addIssue() // Deprecated
// v4
z.treeifyError(error)
z.flattenError(error)
z.prettifyError(error)---
String Format APIs (Breaking)
Methods moved to top-level functions:
// v3
z.string().email()
z.string().uuid()
z.string().url()
z.string().ip()
z.string().cidr()
z.string().ipv4()
z.string().ipv6()
// v4
z.email()
z.uuid()
z.url()
z.ipv4() // .ip() removed
z.ipv6()
z.cidrv4() // .cidr() split
z.cidrv6()
z.base64()
z.base64url()
z.jwt()Stricter UUID Validation
v4 enforces RFC 4122 compliance. Use z.guid() for permissive matching.
Custom Email Regex
z.email({ pattern: "gmail" }) // Gmail-specific
z.email({ pattern: "html5" }) // HTML5 pattern
z.email({ pattern: /custom/ }) // Custom regex---
Number Changes (Breaking)
No Infinite Values
// v4 rejects Infinity and -Infinity
z.number().parse(Infinity); // ThrowsStricter .safe() and .int()
// v3: .safe() accepted floats, .int() accepted any integer
// v4: .safe() only safe integers, .int() only MIN_SAFE to MAX_SAFE
z.number().safe().parse(1.5); // v4: Throws
z.number().int().parse(9007199254740993); // v4: Throws (> MAX_SAFE_INTEGER)New Fixed-Width Types
z.int32() // 32-bit signed
z.uint32() // 32-bit unsigned
z.float32() // 32-bit float
z.int64() // 64-bit signed
z.uint64() // 64-bit unsigned---
Object Changes (Breaking)
Deprecated Methods
// v3
z.object({}).strict()
z.object({}).passthrough()
z.object({}).strip()
z.object({}).merge(other)
z.object({}).deepPartial()
z.object({}).nonstrict()
// v4
z.strictObject({}) // Rejects unknown keys
z.looseObject({}) // Allows unknown keys
z.object({}) // Allows by default
obj.extend(other.shape) // Instead of .merge()
// .deepPartial() removed - nest .partial() manuallypick/omit with refinements
As of v4.3.x, calling .pick() or .omit() on schemas with refinements throws. Rebuild the object from .shape instead:
const Base = z.object({ id: z.string(), name: z.string() });
const Refined = Base.refine(val => val.id.length > 0);
// ❌ throws in v4.3.x
// Refined.pick({ id: true })
// ✅ rebuild from shape
const Picked = z.object({ id: Base.shape.id });Stricter key validation in pick/omit
As of v4.3.x, .pick() and .omit() throw on unknown keys. Ensure keys exist in the schema.
const Schema = z.object({ a: z.string() });
// ❌ throws in v4.3.x
// Schema.pick({ missing: true })extend with refinements
As of v4.3.x, calling .extend() on refined schemas throws. Use .safeExtend() if you need to add fields without dropping refinements:
const Refined = z.object({ a: z.string() }).refine(val => val.a.length > 0);
// ❌ throws in v4.3.x
// Refined.extend({ b: z.number() })
// ✅ preserves refinements
const Extended = Refined.safeExtend({ b: z.number() });Defaults in Optional Fields
const schema = z.object({ a: z.string().default("x").optional() });
schema.parse({});
// v3: {}
// v4: { a: "x" }z.unknown() Optionality
z.object({ data: z.unknown() });
// v3: data was optional in inferred type
// v4: data is required (use .optional() explicitly)---
Default/Prefault (Breaking)
// v3: .default() applied before transforms
// v4: .default() applies to output (post-transform)
const schema = z.string().transform(s => s.length).default(5);
// v4: default is number 5, not string
// Use .prefault() for v3-like behavior
z.string().transform(s => s.length).prefault("hello");
// Missing => parses "hello" => 5---
Record Changes (Breaking)
Key Schema Required
// v3
z.record(z.number()) // Allowed
// v4
z.record(z.string(), z.number()) // RequiredEnum Records Are Exhaustive
const Keys = z.enum(["a", "b"]);
// v4: Creates { a: number; b: number } (exhaustive)
z.record(Keys, z.number())
// v4: Creates { a?: number; b?: number } (optional)
z.partialRecord(Keys, z.number())---
Function Changes (Breaking)
// v3
z.function()
.args(z.string(), z.number())
.returns(z.boolean())
// v4
z.function({
input: [z.string(), z.number()],
output: z.boolean()
}).implement((str, num) => str.length > num)
// Async
z.function({ input: [z.string()], output: z.number() })
.implementAsync(async (str) => str.length)---
Deprecated/Removed APIs
z.nativeEnum() Deprecated
// v3
enum Color { Red, Green, Blue }
z.nativeEnum(Color)
// v4
z.enum(Color) // Overloaded z.enum() handles TS enumsz.promise() Deprecated
// v3
z.promise(z.string())
// v4 (alternative)
z.custom<Promise<string>>()Removed Shorthand Types
// v3
z.ostring() // z.string().optional()
z.onumber() // z.number().optional()
z.oboolean() // z.boolean().optional()
// v4: Removed, use explicit .optional()z.literal() Symbol Support Dropped
// v3
z.literal(Symbol.for("x")) // Allowed
// v4
z.literal(Symbol.for("x")) // Not supportedStatic .create() Factories Dropped
// v3
z.string.create() // Existed
// v4
z.string() // Use directly---
Refine Changes
Type Predicates Ignored
// v3: Type predicates narrowed types
z.unknown().refine((x): x is string => typeof x === "string")
// v4: Type predicates ignored, use .transform() for narrowingctx.path Dropped
// v3
.refine((val) => true, { path: ctx.path })
// v4
.refine((val) => true, { path: ["fieldName"] }) // Explicit path---
New Features in v4
Multiple Literal Values
z.literal([200, 201, 204]) // Union of literalsTemplate Literals
z.templateLiteral(["user_", z.string()])Stringbool
z.stringbool() // "true"/"false" => booleanRecursive Objects (No Casting)
const Category = z.object({
name: z.string(),
get subcategories() { return z.array(Category) }
});File Schemas
z.file().max(5 * 1024 * 1024).mime(["image/png"])Native JSON Schema
z.toJSONSchema(schema, { target: "openapi-3.0" })Metadata System
schema.meta({ title: "User", description: "..." })Internationalization
import { es } from "zod/locales";
z.config(es());Error Pretty-Printing
z.prettifyError(error).overwrite() Method
z.string().overwrite(s => s.trim()) // Transform without type change---
Migration Tools
Automated Codemod
npx zod-v3-to-v4Subpath Imports (Incremental Migration)
import { z } from "zod"; // v4 (default)
import * as z from "zod/mini"; // Zod Mini
import * as z from "zod/v3"; // v3 fallback only if requiredLibrary Authors
{
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}Related skills
FAQ
How do string formats change in v4?
v4 moved string validators to top-level functions such as z.email(), z.uuid(), and z.url(), replacing z.string().email().
How do you generate JSON Schema?
Use z.toJSONSchema(schema), and target 'openapi-3.0' for OpenAPI 3.0 output.