zod-best-practices
Production-ready Zod 4 patterns — schema composition, parsing, error handling, transforms, and performance for type-safe runtime validation
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Production-ready Zod 4 patterns — schema composition, parsing, error handling, transforms, and performance for type-safe runtime validation
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | zod-best-practices |
| description | Production-ready Zod 4 patterns — schema composition, parsing, error handling, transforms, and performance for type-safe runtime validation |
Practical Zod v4 patterns for type-safe runtime validation. Requires TypeScript 5.5+ and strict: true. Ships as zod on npm.
Compose schemas from small, reusable pieces:
const nameSchema = z.object({ first: z.string().min(1), last: z.string().min(1) });
const addressSchema = z.object({ street: z.string(), city: z.string(), zip: z.string() });
const userSchema = z.object({ name: nameSchema, address: addressSchema });
Use .extend() to add fields — replaces the deprecated .merge():
const baseEntity = z.object({ id: z.uuid(), createdAt: z.iso.datetime() });
const userSchema = baseEntity.extend({ email: z.email() });
Use spread syntax for full control:
const userSchema = z.strictObject({ ...baseEntity.shape, email: z.email() });
| Behavior | Zod 3 (deprecated) | Zod 4 |
|---|---|---|
| Strip unknown keys | z.object({...}) | z.object({...}) (same) |
| Throw on unknown keys | .strict() | z.strictObject({...}) |
| Pass through unknowns | .passthrough() | z.looseObject({...}) |
| Validate unknowns | .catchall(schema) | .catchall(schema) (same) |
| Modifier | Behavior |
|---|---|
.optional() | Accepts undefined (key may be absent) |
.nullable() | Accepts null |
.nullish() | Accepts both (prefered - most forgiving) |
.default(v) | Short-circuits — returns v unparsed when input is undefined |
.prefault(v) | Pre-parse default — runs v through the schema |
.catch(v) | Returns v on invalid input instead of throwing |
.default() no longer re-parses. Use .prefault() when the default must be validated/transformed:
const config = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).prefault(20),
});
Defaults inside object properties now apply even within optional fields.
const UserSchema = z.object({ id: z.uuid(), email: z.email() });
type User = z.output<typeof UserSchema>; // after transforms/defaults
type Input = z.input<typeof UserSchema>; // before transforms/defaults
type Same = z.infer<typeof UserSchema>; // shorthand, no transforms
safeParse — never throws. Use for all external data (API, forms, files). Returns { success, data } or { success, error }:
const result = schema.safeParse(input);
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return { fieldErrors };
}
parse — throws ZodError. Use for known-valid data (cache, invariants).parseAsync/safeParseAsync — required when .refine()/.superRefine() returns a promise.Zod 4 promotes format validators to top-level functions. Method forms (z.string().email()) are deprecated.
z.email();
z.uuid();
z.uuidv4();
z.guid();
z.nanoid();
z.ulid();
z.cuid2();
z.jwt();
z.url();
z.httpUrl();
z.ipv4();
z.ipv6();
z.cidrv4();
z.cidrv6();
z.mac();
z.base64();
z.base64url();
z.hex();
z.hash("sha256");
z.e164();
z.emoji();
z.stringbool();
z.iso.date();
z.iso.time();
z.iso.datetime({ offset: true });
z.iso.duration();
z.string().trim().toLowerCase().toUpperCase().normalize(); // built-in transforms
z.email({ pattern: z.regexes.html5Email }); // custom email regex
Model typed string patterns with type-level precision:
const route = z.templateLiteral(["/users/", z.string().uuid()]);
// `/users/${string}`
const css = z.templateLiteral([z.number(), z.enum(["px", "em"])]);
// `${number}px` | `${number}em`
All Zod 4 refinements use a unified error parameter object (replaces deprecated message, errorMap, invalid_type_error, required_error).
.refine()z.string()
.min(8)
.refine((v) => /[A-Z]/.test(v), { error: "Needs uppercase" });
// Options: error (string|function), path (attribute to field), abort (stop on first failure)
z.object({ pw: z.string(), confirm: z.string() }).refine((d) => d.pw === d.confirm, {
error: "No match",
path: ["confirm"],
});
.superRefine() — Multi-Errorz.string().superRefine((val, ctx) => {
if (val.length < 8) ctx.addIssue({ code: "too_small", minimum: 8, type: "string", inclusive: true });
if (!/[A-Z]/.test(val)) ctx.addIssue({ code: "custom", message: "Needs uppercase" });
});
.check() — Fast AlternativeFor performance-sensitive paths (faster than .superRefine()):
z.string().check(z.property("length", z.number().min(10)));
.when() — Conditional Refinementschema.refine(validator, { error: "...", when: (p) => precheck.safeParse(p.value).success });
const slug = z.string().transform((s) => s.toLowerCase().replace(/\s+/g, "-"));
const trimmed = z.string().overwrite((v) => v.trim()); // keeps inferred type
// Bidirectional codec
const stod = z.codec(z.iso.datetime(), z.date(), {
decode: (s) => new Date(s),
encode: (d) => d.toISOString(),
});
stod.parse("2024-01-15T10:30:00.000Z"); // forward → Date
z.encode(stod, new Date()); // reverse → string
Use result.error.issues to inspect failures. .format()/.flatten() are deprecated — use top-level functions:
| Function | Output | Use Case |
|---|---|---|
z.treeifyError(error) | Nested object matching schema shape | Nested error display |
z.flattenError(error) | { formErrors, fieldErrors } | Simple field-level errors |
z.prettifyError(error) | Human-readable string with paths | Debugging |
const { fieldErrors } = z.flattenError(result.error);
// { email: ["Invalid"], name: ["Required"] }
z.string({ error: "Not a string!" });
z.string().min(5, { error: "Too short" });
z.email({ error: (iss) => (iss.input === undefined ? "Required" : "Invalid") });
// Global
z.config({
customError: (iss) => {
if (iss.code === "invalid_type") return `Expected ${iss.expected}, got ${iss.received}`;
},
});
// Per-parse (lower precedence)
schema.parse(input, { error: (iss) => "override", reportInput: true });
// i18n
z.config(z.locales.fr());
Locales: ar, de, en, es, fr, it, ja, ko, nl, pl, pt, ru, sv, th, tr, vi, zhCN, zhTW.
Prefer over z.union when variants share a literal key — faster, clearer errors:
const Response = z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), data: z.unknown() }),
z.object({ status: z.literal("error"), error: z.string() }),
]);
Use getter syntax (replaces z.lazy()):
const Category = z.object({
name: z.string(),
get subcategories() {
return z.array(Category);
},
});
Add explicit return types when TypeScript can't resolve mutual recursion:
get subs(): z.ZodNullable<z.ZodArray<typeof Activity>> { return z.nullable(z.array(Activity)); }
.pick(), .omit(), .partial() all work with recursive schemas.
z.interface() for hot paths — faster than z.object(), but no .extend()/.pick()/.omit().extend() — TypeScript gets quadratically slower per chain.pick()/.omit() are free — derived without re-parsing// ❌ Schema per request
app.post("/data", (req) => z.object({...}).parse(req.body));
// ✅ Module scope
const DataSchema = z.object({...});
z.coerce.number(), z.coerce.boolean(), z.coerce.string() for FormData/query/headersz.uuid().brand<"UserId">() for nominal typing.catch() — fallback on parse failure instead of throwingz.literal(["red", "green", "blue"])schema.toJSONSchema() for OpenAPI/AI toolsTree-shakable ~2KB for edge/serverless. Functional API, no locales or JSON Schema:
import { object, string, email } from "zod/mini";
const s = object({ name: string(), email: email() });
| Mistake | Fix |
|---|---|
parse for untrusted input | Use safeParse |
| Schemas inside handlers | Hoist to module scope |
z.union when discriminant exists | Use z.discriminatedUnion |
Old error APIs (message, errorMap, .format()) | Use unified error + z.flattenError()/z.treeifyError() |
.merge(), .strict(), .passthrough() | Use .extend(), z.strictObject(), z.looseObject() |
z.lazy() for recursive | Use getter syntax |
.default() expecting re-parsed value | Use .prefault() |
.string().email() style | Use z.email() (top-level, tree-shakable) |
Chained .extend() | Use spread: z.object({ ...base.shape, ... }) |
| Forgetting async variants | Use safeParseAsync/parseAsync for async refine |
Security guidelines for TypeScript and Node.js — input validation, injection prevention, authentication hardening, secrets management, HTTP security headers, and common vulnerability patterns
In-memory caching patterns for TypeScript — memoization, async deduplication, TTL, LRU, stale-while-revalidate, and invalidation strategies
Concrete patterns for composing behavior from small units — function composition, middleware, decorators, mixins, monad chaining, builders, plugin systems, strategy composition, and type-level composition for TypeScript
ESM-specific patterns for TypeScript and Node.js — file extensions, import.meta, dynamic imports, dual packages, and module resolution modes
Guidelines for designing and implementing HTTP REST APIs — resource naming, HTTP semantics, status codes, error responses, pagination, and versioning
Production-ready Playwright patterns — config, locators, assertions, page objects, network mocking, and CI setup for reliable E2E tests.