zod-4
Scannednpx machina-cli add skill nklisch/skilltap/zod-4 --openclawZod 4 — Schema Validation Reference
This project uses Zod 4. Many Zod 3 patterns are deprecated or changed. Always use the patterns below.
Type system gotchas: Read references/types.md whenever writing code that passes schemas to functions, uses generics with Zod types, or relies on type inference from .transform(), .default(), .refine(), or .optional(). The type system changed significantly — ZodTypeAny is gone, generics simplified, .refine() no longer narrows, .default() matches output type, and more.
Install: bun add zod@^4
Import: import { z } from "zod/v4"
The bare import { z } from "zod" still gives Zod 3 for backward compatibility. Always use "zod/v4" explicitly.
Breaking Changes from Zod 3
These are the most important changes. Getting these wrong causes runtime errors or type mismatches.
z.record() requires two arguments
// ZOD 3 (WRONG in Zod 4):
z.record(z.string())
// ZOD 4 (CORRECT):
z.record(z.string(), z.string()) // Record<string, string>
z.record(z.string(), z.number()) // Record<string, number>
z.record(z.string(), z.unknown()) // Record<string, unknown>
// Constrained keys with enum:
z.record(z.enum(["a", "b", "c"]), z.number())
// { a: number; b: number; c: number }
String validators moved to top-level
// ZOD 3 (deprecated):
z.string().email()
z.string().uuid()
z.string().url()
// ZOD 4 (preferred):
z.email()
z.uuid()
z.url()
z.emoji()
z.base64()
z.base64url()
z.nanoid()
z.cuid()
z.cuid2()
z.ulid()
z.ipv4() // was z.string().ip()
z.ipv6() // was z.string().ip()
z.cidrv4() // was z.string().cidr()
z.cidrv6() // was z.string().cidr()
z.iso.date() // was z.string().date()
z.iso.time() // was z.string().time()
z.iso.datetime() // was z.string().datetime()
z.iso.duration() // was z.string().duration()
// z.string().email() still works but is deprecated
// The old z.string().ip() and z.string().cidr() are REMOVED — use the specific v4/v6 variants
.strict() / .passthrough() replaced
// ZOD 3 (deprecated):
z.object({ name: z.string() }).strict()
z.object({ name: z.string() }).passthrough()
z.object({ name: z.string() }).strip()
// ZOD 4:
z.strictObject({ name: z.string() }) // Rejects unknown keys
z.looseObject({ name: z.string() }) // Passes through unknown keys
z.object({ name: z.string() }) // Strips unknown keys (default, same as before)
.merge() deprecated
// ZOD 3 (deprecated):
const Combined = SchemaA.merge(SchemaB)
// ZOD 4:
const Combined = SchemaA.extend(SchemaB.shape)
// Or use spread:
const Combined = z.object({ ...SchemaA.shape, ...SchemaB.shape })
z.preprocess() removed
// ZOD 3 (removed):
z.preprocess((val) => String(val), z.string())
// ZOD 4 — use .pipe():
z.unknown().pipe(z.coerce.string())
// Or chain pipes:
z.string().pipe(z.coerce.number()).pipe(z.number().int().positive())
z.nativeEnum() deprecated
// ZOD 3 (deprecated):
z.nativeEnum(MyEnum)
// ZOD 4 — z.enum() handles both:
z.enum(["a", "b", "c"]) // String array
z.enum(MyEnum) // TypeScript enum
// No more `as const` needed for arrays:
z.enum(["a", "b", "c"]) // Infers "a" | "b" | "c" without `as const`
.nonempty() changed
// ZOD 3: .nonempty() inferred [T, ...T[]] tuple type
// ZOD 4: .nonempty() is just .min(1), infers T[]
z.array(z.string()).nonempty() // string[] (not [string, ...string[]])
// For tuple "at least one" pattern:
z.tuple([z.string()], z.string()) // [string, ...string[]]
Error handling overhauled
// ZOD 3 (deprecated):
error.format()
error.flatten()
// ZOD 4:
z.treeifyError(error) // Structured tree: { _errors: [...], field: { _errors: [...] } }
z.prettifyError(error) // Human-readable formatted string
// ZOD 3 (deprecated):
z.setErrorMap(map)
// ZOD 4:
z.config({
customError: (issue) => {
if (issue.code === "invalid_type") {
return { message: `Expected ${issue.expected}, got ${issue.received}` }
}
},
})
.default() behavior changed
// ZOD 4: .default() short-circuits on undefined, default must match OUTPUT type
z.string().transform(v => v.length).default(0) // default is number (output type)
// ZOD 4: Use .prefault() for pre-parse defaults (old Zod 3 behavior)
z.string().prefault("hello").transform(v => v.length)
.refine() no longer narrows types
// ZOD 3: type predicates in .refine() narrowed the output type
// ZOD 4: .refine() ignores type predicates — no type narrowing
.deepPartial() removed
// ZOD 3 (removed):
MySchema.deepPartial()
// ZOD 4: Use .partial() and apply it manually to nested schemas
Internal changes
// Generic signature changed:
// ZOD 3: ZodType<Output, Def extends ZodTypeDef, Input>
// ZOD 4: ZodType<Output, Input>
// ZodTypeAny eliminated — use ZodType directly
// ._def moved to ._zod.def
// ZodEffects → dropped; refinements live in schemas
// ZodPreprocess → ZodPipe
New Features in Zod 4
Unified error parameter
// Simple string message:
z.string({ error: "Must be a string" })
// Dynamic message function:
z.string({ error: (issue) => `Expected string, got ${typeof issue.input}` })
// Per-check messages:
z.string().min(3, { error: "Too short" })
Metadata system
const UserSchema = z.object({
name: z.string().meta({ description: "Full name", example: "Jane Doe" }),
email: z.email().meta({ description: "Primary email" }),
}).meta({ title: "User" })
UserSchema.meta() // { title: "User" }
Built-in JSON Schema conversion
import { toJSONSchema } from "zod/v4/json-schema"
const jsonSchema = toJSONSchema(UserSchema)
// Produces standard JSON Schema, uses .meta() for descriptions
z.literal() accepts arrays
// ZOD 3:
z.union([z.literal("active"), z.literal("inactive"), z.literal("pending")])
// ZOD 4:
z.literal(["active", "inactive", "pending"])
// Inferred: "active" | "inactive" | "pending"
z.templateLiteral()
z.templateLiteral([z.number(), z.literal("px")])
// Matches "10px", "3.5px" — type: `${number}px`
z.file()
z.file().type("image/png").maxSize(5 * 1024 * 1024)
z.stringbool()
z.stringbool()
// "true"/"1"/"yes"/"on" → true
// "false"/"0"/"no"/"off" → false
Automatic discriminated union detection
// ZOD 3: required z.discriminatedUnion("type", [...])
// ZOD 4: z.union() auto-detects discriminator fields
z.union([
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("square"), side: z.number() }),
])
// Internally optimized as discriminated union
z.input<> and z.output<>
type Input = z.input<typeof Schema> // Type before transforms
type Output = z.output<typeof Schema> // Type after transforms (same as z.infer)
zod/v4-mini
Smaller build (~2KB vs ~13KB) for bundle-sensitive contexts. Same API but no built-in error messages — you must provide your own:
import { z } from "zod/v4-mini"
z.string({ error: "Expected string" }).min(1, { error: "Required" })
Core API (unchanged from Zod 3)
These work the same as before:
// Primitives
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
// Objects
z.object({ name: z.string(), age: z.number() })
.partial() // All fields optional
.partial({ name: true }) // Only name optional
.required() // All fields required
.pick({ name: true }) // Only name
.omit({ age: true }) // Everything except age
.extend({ role: z.string() }) // Add fields
.keyof() // z.enum(["name", "age"])
// Arrays and tuples
z.array(z.string()).min(1).max(10)
z.tuple([z.string(), z.number()])
// Unions and intersections
z.union([z.string(), z.number()])
z.intersection(SchemaA, SchemaB)
// Enums
z.enum(["admin", "user", "moderator"])
// Optional and nullable
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
// Transforms
z.string().transform(val => val.length)
// Refinements
z.string().refine(val => val.length > 0, { message: "Required" })
z.object({ a: z.string(), b: z.string() })
.superRefine((data, ctx) => {
if (data.a !== data.b) {
ctx.addIssue({ code: "custom", message: "Must match", path: ["b"] })
}
})
// Coercion
z.coerce.string() // anything → String(x)
z.coerce.number() // anything → Number(x)
z.coerce.boolean() // anything → Boolean(x)
z.coerce.date() // anything → new Date(x)
// Lazy (recursive schemas)
const CategorySchema: z.ZodType<Category> = z.object({
name: z.string(),
children: z.lazy(() => z.array(CategorySchema)),
})
// Parsing
schema.parse(data) // Returns data or throws ZodError
schema.safeParse(data) // Returns { success, data } or { success, error }
await schema.parseAsync(data) // Async version
await schema.safeParseAsync(data)
Pattern: skilltap Schema Definitions
This is how schemas are defined in packages/core/src/schemas/:
import { z } from "zod/v4"
export const SkillFrontmatterSchema = z.object({
name: z.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
description: z.string().min(1).max(1024),
license: z.string().optional(),
compatibility: z.string().max(500).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>
// Validate at data boundaries:
const result = SkillFrontmatterSchema.safeParse(parsed)
if (!result.success) {
console.error(z.prettifyError(result.error))
}
Source
git clone https://github.com/nklisch/skilltap/blob/main/.agents/skills/zod-4/SKILL.mdView on GitHub Overview
Zod 4 introduces major API changes from Zod 3. This reference covers how to define schemas, validate data, and migrate code to zod/v4. It highlights import patterns, top-level string validators, strict/loose object handling, and using extend() for schema composition.
How This Skill Works
Import { z } from "zod/v4" and apply the new Zod 4 patterns such as z.record requiring two arguments, top-level string validators (z.email(), z.uuid(), etc.), and object schemas using z.strictObject() or z.looseObject(). Use .extend() to compose schemas instead of .merge(), and leverage .pipe() for transforms rather than z.preprocess.
When to Use It
- Defining schemas for API requests and responses in a TypeScript project using zod/v4
- Migrating Zod 3 codebases to Zod 4 and adjusting for API changes
- Building strict user input validation for forms with precise object shapes
- Composing complex schemas with extend() instead of merge()
- Validating string data using top-level validators like z.email(), z.uuid(), etc.
Quick Start
- Step 1: Install Zod 4 with bun add zod@^4
- Step 2: Import { z } from "zod/v4"
- Step 3: Create a sample schema and parse data, e.g., const User = z.object({ name: z.string(), email: z.email() }); User.parse(input)
Best Practices
- Import and use zod/v4 explicitly in every module
- Prefer z.strictObject() for strict schemas and z.looseObject() when unknown keys are allowed
- Define records with z.record(valueSchema, keySchema) and rely on explicit key/value types
- Avoid .merge(); use .extend() or object spread to combine schemas
- Use .pipe() for coercion/transforms instead of z.preprocess, and handle errors with new error patterns
Example Use Cases
- Validate a user signup payload: { name, email: z.email(), age: z.number().int() }
- Define API response shapes with strict objects to reject extra fields
- Migrate a Zod 3 schema: replace z.object({...}).strict() with z.strictObject({ ... })
- Create a nested schema using extend to merge two object schemas
- Validate an enum-like set of keys with z.record(z.enum(['a','b','c']), z.number())