zod
SKILL.md
Zod 4 Skill
Zod is a TypeScript-first schema validation library. This skill covers Zod 4 — the current major version. Always use Zod 4 APIs and avoid deprecated/removed methods from Zod 3.
Setup
npm install zod
// Recommended import style
import * as z from "zod";
tsconfig.json — "strict": true is required.
Core Concepts
Defining schemas
const User = z.object({
name: z.string(),
age: z.number().int().min(0),
email: z.email(), // top-level format (preferred over z.string().email())
role: z.enum(["admin", "user"]),
});
Parsing data
// Throws ZodError on failure
const user = User.parse(input);
// Safe: returns { success, data } | { success: false, error }
const result = User.safeParse(input);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error.issues);
}
// Async (required when schema uses async refinements/transforms)
const user = await User.parseAsync(input);
const result = await User.safeParseAsync(input);
Type inference
type User = z.infer<typeof User>;
// For schemas with diverging input/output types (e.g. transforms):
type UserInput = z.input<typeof User>;
type UserOutput = z.output<typeof User>;
Primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
Strings
Validations & transforms
z.string().min(1).max(100)
z.string().length(10)
z.string().regex(/^[a-z]+$/)
z.string().includes("foo")
z.string().startsWith("foo")
z.string().endsWith("bar")
z.string().trim() // transform: trims whitespace
z.string().toLowerCase() // transform: lowercases
z.string().toUpperCase() // transform: uppercases
z.string().nonempty() // shorthand for .min(1)
String formats — use top-level APIs (preferred in Zod 4)
z.email() // NOT z.string().email() (deprecated)
z.url() // NOT z.string().url() (deprecated)
z.uuid() // strict RFC 9562/4122 — use z.guid() for permissive
z.guid() // permissive UUID-like identifier
z.cuid()
z.cuid2()
z.ulid()
z.nanoid()
z.ipv4() // NOT z.string().ip() (dropped)
z.ipv6() // NOT z.string().ip() (dropped)
z.cidrv4() // NOT z.string().cidr() (dropped)
z.cidrv6() // NOT z.string().cidr() (dropped)
z.mac()
z.jwt()
z.base64()
z.base64url() // unpadded by default
z.iso.datetime() // ISO 8601
z.iso.date() // YYYY-MM-DD
z.iso.time() // HH:MM[:SS[.s+]]
To accept both IPv4 and IPv6:
z.union([z.ipv4(), z.ipv6()])
Numbers
z.number() // any finite number (no Infinity)
z.number().min(0).max(100)
z.number().positive()
z.number().negative()
z.number().nonnegative()
z.number().nonpositive()
z.number().multipleOf(5)
z.number().finite()
z.number().safe() // integer within safe range (Zod 4: same as .int())
z.int() // preferred alias for safe integers
z.float32()
z.float64()
Objects
// Default: strips unknown keys
z.object({ name: z.string() })
// Strict: errors on unknown keys (instead of .strict() — deprecated)
z.strictObject({ name: z.string() })
// Loose: passes unknown keys through (instead of .passthrough() — deprecated)
z.looseObject({ name: z.string() })
Object utilities
const Base = z.object({ id: z.string(), name: z.string() });
// Extend (preferred over .merge() which is deprecated)
const Extended = Base.extend({ age: z.number() });
// Even better for large schemas (more tsc-efficient):
const Extended = z.object({ ...Base.shape, age: z.number() });
// Pick / Omit
const NameOnly = Base.pick({ name: true });
const WithoutId = Base.omit({ id: true });
// Partial / Required
const PartialUser = Base.partial();
const RequiredUser = Base.required();
// Access shape
Base.shape.name // => ZodString
// Keyof
Base.keyof() // => ZodEnum<["id", "name"]>
Recursive objects
type Category = { name: string; subcategories: Category[] };
const Category: z.ZodType<Category> = z.object({
name: z.string(),
get subcategories() {
return z.array(Category);
},
});
Arrays
z.array(z.string())
z.array(z.string()).min(1).max(10)
z.array(z.string()).length(5)
z.array(z.string()).nonempty() // behaves like .min(1)
Enums
// String enum (preferred)
const Role = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof Role>; // "admin" | "user" | "guest"
// Access enum object
Role.enum.admin // => "admin"
// Pick/exclude values
Role.extract(["admin"])
Role.exclude(["guest"])
// TypeScript native enum (z.nativeEnum is DEPRECATED — use z.enum instead)
enum Direction { Up = "UP", Down = "DOWN" }
const DirectionSchema = z.enum(Direction);
Optionals, Nullables, Defaults
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
// Default — short-circuits if input is undefined; value must match OUTPUT type
z.string().default("hello")
z.string().toLowerCase().default("hello")
// Prefault — parses the default through the schema; value must match INPUT type
z.string().toLowerCase().prefault("HELLO") // result: "hello"
// Catch — returns fallback on any validation failure
z.number().catch(0)
Unions & Intersections
// Regular union — first match wins
z.union([z.string(), z.number()])
// Discriminated union — more efficient for large unions
z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), data: z.string() }),
z.object({ status: z.literal("error"), message: z.string() }),
])
// Exclusive union (XOR) — exactly one option must match
z.xor(z.object({ a: z.string() }), z.object({ b: z.number() }))
// Intersection
z.intersection(SchemaA, SchemaB)
// Or prefer: z.object({ ...SchemaA.shape, ...SchemaB.shape })
Records, Maps, Sets, Tuples
// Record — now does exhaustiveness checking with enum keys
z.record(z.string(), z.number())
z.record(z.enum(["a", "b"]), z.number()) // exhaustive: both keys required
// Partial record — skips exhaustiveness (replicates Zod 3 behavior)
z.partialRecord(z.enum(["a", "b"]), z.number())
// Loose record — passes non-matching keys through
z.looseRecord(z.string(), z.number())
// Map / Set
z.map(z.string(), z.number())
z.set(z.string()).min(1).max(5)
// Tuple
z.tuple([z.string(), z.number()])
z.tuple([z.string(), z.number()]).rest(z.boolean()) // variadic rest
Transforms & Pipes
// .transform() pipes into a transformation
const schema = z.string().transform(val => val.length);
// inferred type: number
// z.preprocess() — transform BEFORE validation
const schema = z.preprocess(val => Number(val), z.number());
// Manual pipe
const schema = z.string().pipe(z.coerce.number());
Transforms should never throw — push issues onto ctx.issues to report errors:
z.string().transform((val, ctx) => {
const parsed = JSON.parse(val);
if (!parsed.id) ctx.issues.push({ code: "custom", message: "Missing id" });
return parsed;
});
Refinements
// .refine() — custom validation
z.string().refine(val => val.includes("@"), {
error: "Must contain @", // use `error`, NOT `message` (deprecated)
});
// Abort on first failure
z.string().refine(val => val.length > 3, { abort: true, error: "Too short" });
// Custom error path (useful on objects)
z.object({ pw: z.string(), confirm: z.string() }).refine(
data => data.pw === data.confirm,
{ path: ["confirm"], error: "Passwords must match" }
);
// .superRefine() — access full ctx, emit multiple issues
z.string().superRefine((val, ctx) => {
if (val.length < 3) ctx.issues.push({ code: "too_small", minimum: 3, type: "string", inclusive: true });
});
Error Handling
import * as z from "zod";
const result = schema.safeParse(input);
if (!result.success) {
const err = result.error;
// Human-readable string
console.log(z.prettifyError(err));
// Tree structure (replaces deprecated .format())
const tree = z.treeifyError(err);
// Flat structure (replaces deprecated .flatten())
const flat = z.flattenError(err);
// { formErrors: string[], fieldErrors: { [key]: string[] } }
// ZodError-shaped nested object (replaces deprecated .format())
const formatted = z.formatError(err);
// Raw issues array
err.issues;
}
Customize error messages using the error param (not message, not errorMap):
z.string({ error: "Must be a string" })
z.string().min(3, { error: "At least 3 chars" })
// Dynamic error (error map function)
z.string({ error: (issue) => issue.code === "too_small" ? "Too short!" : undefined })
Coercion
z.coerce.string() // String(input)
z.coerce.number() // Number(input)
z.coerce.boolean() // Boolean(input)
z.coerce.date() // new Date(input)
z.coerce.bigint() // BigInt(input)
Input type of coerced schemas is unknown.
JSON Schema Conversion
// Zod → JSON Schema
const jsonSchema = z.toJSONSchema(MySchema);
// JSON Schema → Zod
const zodSchema = z.fromJSONSchema(jsonSchema);
Miscellaneous
// Literal
z.literal("admin")
z.literal(42)
z.literal(true)
z.literal([null, undefined]) // multiple literals (Zod 4)
// String boolish values
z.stringbool() // "true"/"1"/"yes" → true, "false"/"0"/"no" → false
// Branded types (nominal typing)
const UserId = z.string().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// Readonly
z.array(z.string()).readonly()
// Custom schema
z.custom<`${string}@${string}`>(val => typeof val === "string" && val.includes("@"))
// Instanceof
z.instanceof(Error)
// Function validation
const fn = z.function({
input: z.tuple([z.string(), z.number()]),
output: z.string(),
}).implement((name, age) => `${name} is ${age}`);
// JSON value
z.json() // any JSON-encodable value
// Metadata
z.string().meta({ description: "User's email address" })
z.string().describe("User's email") // shorthand
Deprecated / Removed in Zod 4 — Never Use These
| ❌ Zod 3 (avoid) | ✅ Zod 4 (use instead) |
|---|---|
z.string().email() |
z.email() |
z.string().url() |
z.url() |
z.string().uuid() |
z.uuid() |
z.string().ip() |
z.union([z.ipv4(), z.ipv6()]) |
z.string().cidr() |
z.union([z.cidrv4(), z.cidrv6()]) |
z.nativeEnum(Enum) |
z.enum(Enum) |
z.object({}).strict() |
z.strictObject({}) |
z.object({}).passthrough() |
z.looseObject({}) |
z.object({}).strip() |
z.object({}) (default behavior) |
A.merge(B) |
A.extend(B.shape) or spread syntax |
z.record(valueSchema) |
z.record(z.string(), valueSchema) |
z.promise() |
await before parsing; rarely needed |
z.deepPartial() |
(removed — no direct replacement) |
schema.refine(fn, { message: "" }) |
schema.refine(fn, { error: "" }) |
{ errorMap: ... } |
{ error: ... } |
{ invalid_type_error: "" } |
{ error: "" } |
{ required_error: "" } |
{ error: "" } |
error.format() |
z.treeifyError(error) / z.formatError() |
error.flatten() |
z.flattenError(error) |
error.addIssue() |
push directly to err.issues |
z.ZodTypeAny |
z.ZodType |
z.ostring(), z.onumber(), etc. |
z.string().optional(), etc. |
Best Practices
- Prefer
safeParseoverparsefor user input — avoid try/catch noise. - Use top-level string format APIs (
z.email(),z.url(), etc.) — they're tree-shakable and less verbose. - Colocate types with schemas — use
z.infer<typeof Schema>rather than maintaining separate type definitions. - Use
z.discriminatedUnioninstead ofz.unionwhen schemas share a literal discriminator key — it's significantly faster. - Use spread syntax for merging large schemas:
z.object({ ...A.shape, ...B.shape })— it's more efficient than.extend()chains and avoids TypeScript's quadratic slowdown. - Validate at boundaries (API routes, form inputs, env vars) and trust the inferred types everywhere else.
- Use
.default()for post-parse defaults (value is output type); use.prefault()when you want the default to go through parsing (value is input type). - Never throw inside
.refine()or.transform()— push toctx.issuesinstead. - Use
asyncparsing methods (.parseAsync,.safeParseAsync) whenever your schema contains async refinements or transforms. - For environment variables, use
z.coerceorz.stringbool()to handle string inputs fromprocess.env.
// Example: environment variable schema
const Env = z.object({
PORT: z.coerce.number().default(3000),
DEBUG: z.stringbool().default(false),
DATABASE_URL: z.url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
const env = Env.parse(process.env);
Weekly Installs
1
Repository
rnldsalili/ai-skillsFirst Seen
4 days ago
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1