zod-skill
AGENTS.md24 starsChanged 9 days agoMITView on GitHub
Every rule in rules/, expanded, for agents that read one file. Source: SKILL.md and rules/*.md. Baseline: zod ^4.6.0, checked against zod 4.6.7. Skill version 2.0.0. Zod is for runtime type validation — parsing untrusted data at system boundaries (API input, form data, env vars, external services). For compile-time-only types, plain TypeScript is sufficient. Impact: CRITICAL parse-use-safeParse · CRITICAL · Use safeParse() instead of parse() to avoid throwing on invalid input. parse() throws a ZodError on invalid input. Wrapping it in…
- Reads credentials
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repositoryOne other file it gives its agents.
- zodSkill
The file
We split this AGENTS.md into the rules it gives an agent
6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 5
Every rule in rules/, expanded, for agents that read one file.
- Very specific: line 77
When a schema contains async refinements (.refine(async ...)) or async transforms (.transform(async ...)), calling synchronous .parse() or …
- Very specific: line 157
When the type comes first, from a generated client or a type you do not own, check the schema against it with z.toZod<T>() (Zod 4.5).
- Distinctive: line 396
When TypeScript reports TS7023 ("implicitly has return type 'any'") on a getter, annotate the getter's return type:
- Very specific: line 554
When the value must also go back to a string, use a codec instead (see pattern-codecs).
- Very specific: line 1237
When safeParse() calls are scattered throughout domain logic, every function must handle unknown input and validation errors.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
<!-- Generated by .github/check/agents.ts. Do not edit. -->Zod: compiled rules
Every rule in rules/, expanded, for agents that read one file. Source: SKILL.md and rules/*.md.
Baseline: zod ^4.6.0, checked against zod 4.6.7. Skill version 2.0.0.
Abstract
Zod is for runtime type validation — parsing untrusted data at system boundaries (API input, form data, env vars, external services). For compile-time-only types, plain TypeScript is sufficient.
Table of Contents
- Parsing & Type Safety — CRITICAL
- Schema Design — CRITICAL
- Refinements & Transforms — HIGH
- Error Handling — HIGH
- Performance & Composition — MEDIUM
- v4 Migration — MEDIUM
- Advanced Patterns — MEDIUM
- Architecture & Boundaries — CRITICAL/HIGH
- Observability — HIGH/MEDIUM
1. Parsing & Type Safety
Impact: CRITICAL
Use safeParse() for User Input
parse-use-safeParse · CRITICAL · Use safeParse() instead of parse() to avoid throwing on invalid input.
Problem
parse() throws a ZodError on invalid input. Wrapping it in try/catch is verbose and error-prone — you lose the discriminated success/error result and risk catching unrelated errors.
Incorrect
const UserSchema = z.object({ name: z.string(), email: z.email() })
// BUG: try/catch is verbose and catches unrelated errors
function validateInput(data: unknown) {
try {
const result = UserSchema.parse(data)
return { success: true, data: result }
} catch (e) {
if (e instanceof z.ZodError) {
return { success: false, errors: e.issues }
}
throw e // rethrow non-Zod errors
}
}Correct
function validateInput(data: unknown) {
const result = UserSchema.safeParse(data)
if (!result.success) {
return { success: false, errors: result.error.issues }
}
return { success: true, data: result.data }
}Why
safeParse() returns a discriminated union { success: true, data } | { success: false, error }. No exceptions, no try/catch, no risk of catching unrelated errors. Use parse() only when invalid data is truly exceptional (e.g., internal config that should never be wrong). When you need only a yes or no, .validate() returns a boolean without building an error (zod.dev/basics). ZodError.errors from Zod 3 is removed; read .issues.
Use parseAsync for Async Refinements
parse-async-required · CRITICAL · Must use parseAsync()/safeParseAsync() when schema has async refinements or transforms.
Problem
When a schema contains async refinements (.refine(async ...)) or async transforms (.transform(async ...)), calling synchronous .parse() or .safeParse() will throw an error. The async refinement will not execute.
Incorrect
const UniqueEmail = z.email().refine(
async (email) => !(await db.users.exists(email)),
{ error: "Email already registered" }
)
// BUG: sync parse with an async refinement throws $ZodAsyncError
const result = UniqueEmail.safeParse(input)Correct
const UniqueEmail = z.email().refine(
async (email) => !(await db.users.exists(email)),
{ error: "Email already registered" }
)
// CORRECT: use safeParseAsync for schemas with async refinements
const result = await UniqueEmail.safeParseAsync(input)
if (!result.success) {
console.log(result.error.issues)
}Why
Zod separates sync and async parsing. If any refinement or transform in the schema chain is async, you must use parseAsync() or safeParseAsync(). The sync version throws $ZodAsyncError ("Encountered Promise during synchronous parse"); it does not skip the refinement. The same holds for .validate(), whose async form is .validateAsync(). Test the async path, because a happy-path test of another schema does not reach it.
Infer Types from Schemas
parse-infer-types · CRITICAL · Use z.infer<typeof Schema> for output types. Never manually duplicate types.
Problem
Manually defining TypeScript interfaces alongside Zod schemas creates duplicate type definitions that drift apart. When the schema changes, the manual type is forgotten.
Incorrect
// BUG: manual type will drift from schema
interface User {
name: string
email: string
age: number
}
const UserSchema = z.object({
name: z.string(),
email: z.email(),
age: z.number().min(0),
})
// These can silently diverge when schema is updatedCorrect
const UserSchema = z.object({
name: z.string(),
email: z.email(),
age: z.number().min(0),
})
// Output type (after parsing/transforms)
type User = z.infer<typeof UserSchema>
// Input type (before transforms — useful for forms)
type UserInput = z.input<typeof UserSchema>Why
z.infer extracts the output type (after transforms). z.input extracts the input type (before transforms). These always stay in sync with the schema. Use z.infer for most cases; use z.input when you need the pre-transform shape (e.g., form state where a date field is a string before being transformed to Date).
When the type comes first, from a generated client or a type you do not own, check the schema against it with z.toZod<T>() (Zod 4.5). It fails to compile on any difference, where satisfies z.ZodType<T> lets extra keys and z.any() through:
type Player = { username: string; xp: number }
const Player = z.toZod<Player>()(
z.object({
username: z.string(),
xp: z.number(),
})
)Use .validate() When You Need Only a Boolean
parse-validate-boolean · MEDIUM · Use .validate() for a yes or no, because it builds no error; use safeParse() when you need the parsed value.
Problem
.safeParse(x).success builds a full ZodError for each failure and then discards it. .validate() (Zod 4.6) returns a boolean, stops at the first failure and builds no error. It has a trap: it narrows the value to the schema's input type and returns no output, so after a transform, a default or a codec the narrowed value is the raw input.
Incorrect
const Item = z.object({ id: z.string(), price: z.number().nonnegative() })
// Builds and discards a ZodError for every invalid item
function validItems(items: unknown[]) {
return items.filter((item) => Item.safeParse(item).success)
}
const Port = z.string().regex(z.regexes.integer).transform(Number)
function nextPort(raw: unknown) {
if (Port.validate(raw)) {
return raw + 1 // BUG: raw has the input type, string, so "8080" + 1 is "80801"
}
return undefined
}Correct
function validItems(items: unknown[]) {
return items.filter((item) => Item.validate(item))
}
function nextPort(raw: unknown) {
const result = Port.safeParse(raw)
return result.success ? result.data + 1 : undefined
}Why
The zod.dev basics page measures .validate() as up to 16x faster than .safeParse().success on invalid input, and the 4.6 release post measures up to 35x on a schema compiled with z.compile(). Use it for filters, guards and routing decisions. Use .safeParse() when you need the transformed value or the issues.
On a schema with an async refinement, .validate() throws, as .safeParse() does; use .validateAsync().
2. Schema Design
Impact: CRITICAL
Handle Unknown Keys Explicitly
schema-object-unknowns · CRITICAL · Default z.object() strips unknown keys. Use strictObject or looseObject for explicit behavior.
Problem
z.object() silently strips any keys not defined in the schema. This can cause data loss when forwarding payloads, storing extra metadata, or proxying API responses.
Incorrect
const Config = z.object({
host: z.string(),
port: z.number(),
})
// BUG: unknown keys are silently dropped
const input = { host: "localhost", port: 3000, debug: true, logLevel: "verbose" }
const result = Config.parse(input)
// result = { host: "localhost", port: 3000 } — debug and logLevel are goneCorrect
// REJECT unknown keys (API input validation)
const StrictConfig = z.strictObject({
host: z.string(),
port: z.number(),
})
// Throws on { host: "localhost", port: 3000, debug: true }
// PRESERVE unknown keys (proxy, forwarding)
const LooseConfig = z.looseObject({
host: z.string(),
port: z.number(),
})
// Passes through { host: "localhost", port: 3000, debug: true, logLevel: "verbose" }
// STRIP unknown keys (default — when you explicitly want stripping)
const SafeConfig = z.object({
host: z.string(),
port: z.number(),
})Why
| Variant | Unknown keys | Use when |
z.object() | Strips | Sanitizing user input, you want only known fields |
z.strictObject() | Rejects (error) | API contracts, config validation — no surprises |
z.looseObject() | Preserves | Proxying data, forwarding payloads, middleware |
Use Discriminated Unions
schema-union-discriminated · CRITICAL · Use z.discriminatedUnion() for tagged object unions instead of z.union().
Problem
z.union() tries each branch sequentially until one matches. For object unions with a shared discriminator field (like type or kind), this is slow and produces confusing error messages that show issues from every branch.
Incorrect
// BAD: tries each branch sequentially, error shows issues from all branches
const Shape = z.union([
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("square"), side: z.number() }),
z.object({ type: z.literal("rect"), width: z.number(), height: z.number() }),
])
// { type: "triangle" } fails with one invalid_union issue at path [], message "Invalid input",
// and an `errors` array holding every branch's issuesCorrect
// GOOD: picks the branch by the discriminator; issues name the failing field
const Shape = z.discriminatedUnion("type", [
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("square"), side: z.number() }),
z.object({ type: z.literal("rect"), width: z.number(), height: z.number() }),
])
// { type: "triangle" } fails at path ["type"]:
// "Invalid discriminator value. Expected 'circle' | 'square' | 'rect'"
// { type: "circle", radius: "x" } reports only the circle branch: invalid_type at ["radius"]Why
discriminatedUnion checks the discriminator field first, then only validates the matching branch. It does not try the other branches, so the issues name the field that is wrong. In Zod 4 a discriminator can be a literal, an enum, a union of literals, null or undefined, and a discriminated union can be an option of another one (zod.dev/v4). z.getDiscriminatedOption(Shape, "circle") (Zod 4.5) returns one branch as a ZodObject. To require that exactly one branch matches, use z.xor([a, b]).
Coercion Pitfalls — Boolean Strings
schema-coercion-pitfalls · CRITICAL · z.coerce.boolean() uses JavaScript Boolean() which makes "false" → true. Use z.stringbool().
Problem
z.coerce.boolean() wraps the input in JavaScript's Boolean() constructor. Boolean("false") is true because any non-empty string is truthy. This silently corrupts form data and environment variables.
Incorrect
// BUG: Boolean("false") === true, Boolean("0") === true
const Settings = z.object({
debug: z.coerce.boolean(),
verbose: z.coerce.boolean(),
})
Settings.parse({ debug: "false", verbose: "0" })
// { debug: true, verbose: true } — both are wrong!Correct
// GOOD: z.stringbool() handles string boolean representations correctly
const Settings = z.object({
debug: z.stringbool(),
verbose: z.stringbool(),
})
Settings.parse({ debug: "false", verbose: "0" })
// { debug: false, verbose: false }
// z.stringbool() accepts, case-insensitively: "true"/"false", "1"/"0", "yes"/"no",
// "on"/"off", "y"/"n", "enabled"/"disabled". Anything else is an invalid_value issue.
// Change the sets with z.stringbool({ truthy: [...], falsy: [...] }) or { case: "sensitive" }.Why
z.stringbool() is purpose-built for string-to-boolean conversion from forms, env vars, and query parameters. It understands common string representations of boolean values. z.coerce.boolean() should only be used when you actually want JavaScript truthiness semantics.
Recursive Schema Design
schema-recursive-types · CRITICAL · Define recursive schemas with a getter. Cyclic input parses since Zod 4.5.
Problem
A schema for a tree (categories, comments, an org chart) refers to itself. A direct self-reference fails, because the variable is not yet defined when the object literal runs. The Zod 3 pattern, z.lazy() with a hand-written interface, still works in Zod 4, but the interface duplicates the schema and drifts from it.
Incorrect
// BUG: the hand-written interface and the schema can drift apart
interface CategoryShape {
name: string
children: CategoryShape[]
}
const Category: z.ZodType<CategoryShape> = z.object({
name: z.string(),
children: z.lazy(() => z.array(Category)),
})Correct
// GOOD: a getter defers the reference until parse time, and z.infer resolves the type
const Category = z.object({
name: z.string(),
get children() {
return z.array(Category)
},
})
type Category = z.infer<typeof Category>
// { name: string; children: Category[] }When TypeScript reports TS7023 ("implicitly has return type 'any'") on a getter, annotate the getter's return type:
const Activity = z.object({
name: z.string(),
get subactivities(): z.ZodNullable<z.ZodArray<typeof Activity>> {
return z.nullable(z.array(Activity))
},
})Why
The getter is the documented way to define recursive objects in Zod 4 (zod.dev/api, "Recursive objects"). The result is a plain ZodObject, so .pick(), .omit(), .partial() and .extend() keep working. Mutually recursive schemas (a User with posts, a Post with an author) use the same pattern.
Since Zod 4.5, cyclic input parses: an object that contains itself comes back as an output graph with the same cycles. Zod Mini needs a memoizer registered for this (zod.dev/api). z.lazy() is not removed and not deprecated; z.json() is built on it.
Parse a JSON String with a Codec, Not z.json()
schema-json-string · HIGH · z.json() checks a value that is already parsed and does not parse a string; decode JSON text with a codec that reports bad JSON as an issue.
Problem
z.json() checks that a value can be encoded as JSON. A string can be, so z.json().parse('{"name":"Ada"}') returns the same string. Code that gives JSON text to z.json() gets a string where it expects an object. JSON.parse() inside .transform() is no better: on bad text it throws a SyntaxError, and the error escapes safeParse().
Incorrect
const Body = z.object({ name: z.string() })
// BUG: the result is the string '{"name":"Ada"}', not an object
const notParsed = z.json().parse('{"name":"Ada"}')
// BUG: on "{bad", JSON.parse throws out of safeParse() instead of returning success: false
const FromText = z.string().transform((s) => JSON.parse(s)).pipe(Body)Correct
const jsonCodec = <T extends z.core.$ZodType>(schema: T) =>
z.codec(z.string(), schema, {
decode: (text, ctx) => {
try {
return JSON.parse(text)
} catch (err) {
ctx.issues.push({
code: "invalid_format",
format: "json",
input: text,
message: err instanceof Error ? err.message : "Invalid JSON",
})
return z.NEVER
}
},
encode: (value) => JSON.stringify(value),
})
const BodyFromText = jsonCodec(Body)
BodyFromText.decode('{"name":"Ada"}') // { name: "Ada" }
BodyFromText.safeDecode("{bad").success // false: an invalid_format issue with format "json"
BodyFromText.encode({ name: "Ada" }) // '{"name":"Ada"}'Why
Use z.json() for a value that is already parsed, such as a JSON column or a field typed as arbitrary JSON. For JSON text, the codec turns bad text into an issue, validates the result with your schema, and encodes the value back. Zod does not export the codec; it is a recipe from zod.dev/codecs. See references/codecs.md for the other recipes.
3. Refinements & Transforms
Impact: HIGH
Never Throw in Refinements
refine-never-throw · HIGH · Refinement and transform functions must never throw. Return false from .refine(), add issues in .superRefine(), push onto ctx.issues in .transform().
Problem
An exception thrown inside a .refine() or .transform() function is not turned into a Zod issue. It escapes .parse() and .safeParse() as a plain Error, so a caller that relies on safeParse() never throwing gets an unhandled exception.
Incorrect
// BUG: the throw escapes safeParse()
const PositiveNumber = z.number().refine((n) => {
if (n <= 0) throw new Error("Must be positive")
return true
})Correct
// GOOD: .refine() returns a boolean; its function receives only the value
const PositiveNumber = z.number().refine((n) => n > 0, { error: "Must be positive" })
// GOOD: .superRefine() receives ctx and can add several issues
const Password = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: "custom", message: "Password must be at least 8 characters", input: val })
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: "custom", message: "Password must contain an uppercase letter", input: val })
}
})
// GOOD: a transform reports a failure by pushing onto ctx.issues and returning z.NEVER
const NumericString = z.string().transform((val, ctx) => {
const n = Number(val)
if (Number.isNaN(n)) {
ctx.issues.push({ code: "custom", message: "Not a number", input: val })
return z.NEVER
}
return n
})Why
.refine() expects a boolean, or a Promise<boolean> for an async check. Its function gets the value only, with no ctx. .superRefine() gets (value, ctx) and reports with ctx.addIssue(). A .transform() gets (value, ctx) and reports with ctx.issues.push() (zod.dev/api, "Refinements" and "Transforms"). Each of these becomes a Zod issue in the result; a throw does not.
Refine for Validation, Transform for Conversion
refine-vs-transform · HIGH · Use .refine() to validate, .transform() to convert, and .pipe() to validate the converted value.
Problem
Validating inside .transform() by throwing bypasses Zod's errors: the exception escapes safeParse(). Checking a numeric string with parseInt accepts strings that only start with digits, because parseInt("12abc") returns 12.
Incorrect
// BUG: the throw escapes safeParse() as a plain Error
const ThrowingInt = z.string().transform((val) => {
const n = Number.parseInt(val, 10)
if (Number.isNaN(n)) throw new Error("Not a number")
return n
})
// BUG: parseInt("12abc") is 12, so "12abc" passes
const LooseInt = z
.string()
.refine((val) => !Number.isNaN(Number.parseInt(val, 10)), { error: "Must be a numeric string" })
.transform((val) => Number.parseInt(val, 10))Correct
// GOOD: check the string, convert it, then check the number
const StringToInt = z.string().regex(z.regexes.integer).transform(Number).pipe(z.int().min(0))
StringToInt.parse("42") // 42
StringToInt.safeParse("12abc").success // falseWhen the value must also go back to a string, use a codec instead (see pattern-codecs).
Why
.refine() answers "is this valid?" and adds an issue when it is not. .transform() returns a new value and must not throw (zod.dev/api, "Transforms"). .pipe() feeds the output of one schema into the next, so the converted value is validated with its own checks. To report an issue from inside a transform, push it onto ctx.issues and return z.NEVER.
Cross-Field Validation
refine-cross-field · HIGH · Refine the parent object and set path to the field that shows the error.
Problem
A refinement on one field sees only that field's value. A rule that compares two fields, such as a password and its confirmation, belongs on the parent object, with path set so that the error shows on the right field.
Incorrect
// BUG: the refinement on confirm cannot see password, so it cannot compare them
const Form = z.object({
password: z.string().min(8),
confirm: z.string().refine((val) => val.length > 0, {
error: "Passwords don't match",
}),
})Correct
// GOOD: refine the object, and put the issue on confirm
const Form = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
error: "Passwords don't match",
path: ["confirm"],
})An object refinement does not run when any field has a type error, even a field it does not read. A failed check such as .min() does not stop it. Use when to run it as soon as the fields it reads are valid:
const Base = z.object({
password: z.string().min(8),
confirm: z.string(),
nickname: z.string(),
})
const Signup = Base.refine((data) => data.password === data.confirm, {
error: "Passwords don't match",
path: ["confirm"],
when(payload) {
return Base.pick({ password: true, confirm: true }).safeParse(payload.value).success
},
})To report more than one issue, use .superRefine() and add each issue with its own path:
const DateRange = z
.object({ start: z.date(), end: z.date(), label: z.string() })
.superRefine((data, ctx) => {
if (data.end < data.start) {
ctx.addIssue({ code: "custom", path: ["end"], message: "End is before start", input: data.end })
}
if (data.label.trim() === "") {
ctx.addIssue({ code: "custom", path: ["label"], message: "Label is empty", input: data.label })
}
})Why
path puts the issue under the field, so z.flattenError() reports it in fieldErrors.confirm and a form library shows it next to that input. .refine() covers a single condition (zod.dev/api, "Refinements"). The zod.dev page for the zod package lists .superRefine() as deprecated in favour of .check(), but the API page documents it and the 4.6.7 type definitions do not mark it deprecated.
4. Error Handling
Impact: HIGH
Custom Error Messages (v4 API)
error-custom-messages · HIGH · Customize messages with the error parameter. required_error and invalid_type_error are gone; message is deprecated.
Problem
Zod 4 dropped required_error and invalid_type_error. TypeScript rejects them; plain JavaScript passes them and Zod ignores them, so the default message appears instead of yours. The message parameter still works but is deprecated.
Incorrect
// BUG: Zod 4 dropped these parameters, so the custom messages never appear
const Name = z.string({
// @ts-expect-error required_error and invalid_type_error are not Zod 4 parameters
required_error: "Name is required",
invalid_type_error: "Name must be a string",
})
// `message` still works, but Zod 4 documents it as deprecated
const Age = z.number().min(18, { message: "Must be 18+" })Correct
// GOOD: the error parameter takes a string
const Age = z.number().min(18, { error: "Must be 18+" })
// GOOD: or the string alone
const AgeShort = z.number().min(18, "Must be 18+")
// GOOD: a function tells a missing value from a wrong type
const Name = z.string({
error: (issue) => (issue.input === undefined ? "Name is required" : "Name must be a string"),
})Why
Zod 4 puts every error customization under one error parameter (zod.dev/error-customization). It takes a string, or a function that receives the issue (code, input, path, and fields such as minimum) and returns a string, or undefined to fall through to the next error map. A schema-level error covers the issues its own checks raise, so z.string("Invalid name").min(5) reports "Invalid name" for a short string.
Error Formatting Functions
error-formatting · HIGH · Use z.flattenError() for flat forms, z.treeifyError() for nested data, z.prettifyError() for logs. z.formatError() is deprecated.
Problem
Zod 4 documents z.formatError() and the .format() and .flatten() methods on ZodError as deprecated. They still run. The top-level functions replace them.
Incorrect
const Signup = z.object({ email: z.email(), age: z.number().min(18) })
const result = Signup.safeParse(data)
if (!result.success) {
// the docs mark z.formatError() deprecated in favour of z.treeifyError()
const formatted = z.formatError(result.error)
}Correct
const Signup = z.object({
email: z.email(),
address: z.object({ zip: z.string().length(5) }),
})
const result = Signup.safeParse(data)
if (!result.success) {
// flat forms: { formErrors: string[], fieldErrors: { email?: string[], address?: string[] } }
const flat = z.flattenError(result.error)
flat.fieldErrors.email // e.g. ["Invalid email address"]
// nested data: a tree that mirrors the schema; use optional chaining
const tree = z.treeifyError(result.error)
tree.properties?.address?.properties?.zip?.errors
// logs and CLIs: one line per issue, with "→ at <path>" under each
const pretty = z.prettifyError(result.error)
}Why
| Function | Output | Use case |
z.flattenError() | { formErrors, fieldErrors } | One-level forms; nested issues land under the top-level key |
z.treeifyError() | { errors, properties?, items? }, nested like the schema | Nested forms, arrays, recursive schemas |
z.prettifyError() | A multi-line string | Logs, debugging, CLI output |
Source: zod.dev/error-formatting.
reportInput Leaks Sensitive Data
error-input-security · HIGH · Never enable reportInput in production. It embeds raw input values in error messages.
Problem
The reportInput parse option includes the raw input value in each issue. Zod leaves it out by default for this reason (zod.dev/error-customization). If enabled in production, sensitive data (passwords, tokens, PII) ends up in error logs, monitoring systems, and API responses.
Incorrect
const UserSchema = z.object({ email: z.email(), password: z.string().min(12) })
// BUG: leaks passwords, tokens, PII into error messages
app.post("/register", (req, res) => {
const result = UserSchema.safeParse(req.body, { reportInput: true })
if (!result.success) {
// error.issues[0].input could contain: "myP@ssw0rd123"
logger.error(result.error.issues)
res.status(400).json({ errors: result.error.issues })
}
})Correct
// GOOD: only use reportInput in development/debugging
const parseOptions = {
reportInput: process.env.NODE_ENV === "development",
}
app.post("/register", (req, res) => {
const result = UserSchema.safeParse(req.body, parseOptions)
if (!result.success) {
// In production, issues won't contain raw input values
const flat = z.flattenError(result.error)
res.status(400).json({ errors: flat.fieldErrors })
}
})Why
Error issues are routinely logged, sent to error monitoring (Sentry, Datadog), and sometimes returned in API responses. Including raw input values in these flows violates data privacy principles and can expose sensitive user data.
5. Performance & Composition
Impact: MEDIUM
Spread Over Extend for Large Schemas
perf-extend-spread · MEDIUM · Use spread syntax with .shape over .extend() for large schemas to reduce compile-time cost.
Problem
.extend() is expensive for the TypeScript compiler on large schemas, and a TypeScript limitation makes chained calls quadratically more expensive (zod.dev/api, ".extend()").
Incorrect
// BAD: each .extend() creates a new TS type — slow compile
const Base = z.object({ id: z.string(), created: z.date() })
const WithName = Base.extend({ name: z.string() })
const WithEmail = WithName.extend({ email: z.email() })
const WithRole = WithEmail.extend({ role: z.enum(["admin", "user"]) })
// 4 intermediate types for TypeScript to resolveCorrect
const Base = z.object({ id: z.string(), created: z.date() })
const Extra = { name: z.string(), email: z.email(), role: z.enum(["admin", "user"]) }
// GOOD: single z.object with spread — one type instantiation
const Full = z.object({ ...Base.shape, ...Extra })Why
.extend() is fine for one-off extensions. For large hierarchies, spreading .shape into one z.object() keeps compile times down, and the same syntax works in Zod Mini. The two forms differ at runtime:
Base.extend({...}) | z.object({ ...Base.shape, ... }) | |
Strictness of Base | Kept | Lost: you choose z.object, z.strictObject or z.looseObject |
Refinements on Base | Kept; throws if a new key overwrites an existing one | Lost: .shape holds only the fields |
Overwrite a key on a refined Base | Use .safeExtend(), which also checks the new schema is assignable | Allowed |
Measured on zod 4.6.7. The zod.dev API page says .extend() throws on any schema with refinements; 4.6.7 throws only when a key is overwritten.
Reuse Schemas with Composition
perf-reuse-schemas · MEDIUM · Define schemas once. Use .pick(), .omit(), .partial() to derive variants.
Problem
Duplicating field definitions across related schemas (Create, Update, Response) leads to drift. When a field changes, only some schemas get updated.
Incorrect
// BAD: fields duplicated across schemas
const UserCreate = z.object({
name: z.string(),
email: z.email(),
password: z.string().min(8),
})
const UserUpdate = z.object({
name: z.string().optional(),
email: z.email().optional(),
// forgot password field — drift
})
const UserResponse = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
createdAt: z.date(),
})Correct
// GOOD: single source, derive variants
const User = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
password: z.string().min(8),
createdAt: z.date(),
})
const UserCreate = User.omit({ id: true, createdAt: true })
const UserUpdate = User.pick({ name: true, email: true }).partial()
const UserResponse = User.omit({ password: true })Why
pick(), omit(), partial(), and required() derive new schemas from a base. Changes to the base propagate automatically to all derived schemas. The inferred types also stay in sync. .exactPartial() (Zod 4.5) makes keys omittable without accepting an explicit undefined, and z.deepPartial() makes nested objects partial too. .partial() and z.deepPartial() throw on an object that carries its own refinement.
Zod Mini Only Under Strict Bundle Limits
perf-zod-mini · MEDIUM · Use Zod Mini only for client bundles with strict size limits; import it from zod/mini and load a locale.
Problem
Zod Mini has the same features as Zod behind a functional API that bundlers can tree-shake. The saving is a few kilobytes, and the API is harder to discover. The zod.dev Zod Mini page recommends regular Zod unless bundle size is an uncommonly strict constraint, and says the saving does not matter on a server. Zod Mini also loads no locale, so every message reads "Invalid input" until you load one.
Incorrect
// BAD: Zod Mini on a server, where a few kilobytes do not matter
import * as z from "zod/mini"
const Order = z.object({
sku: z.string().check(z.minLength(1)),
quantity: z.int().check(z.positive()),
})
// and with no locale loaded, every issue message is "Invalid input"Correct
// GOOD: a size-constrained client bundle that shows messages to users
import * as z from "zod/mini"
import { en } from "zod/locales"
z.config(en())
const LoginForm = z.object({
email: z.email(),
password: z.string().check(z.minLength(8)),
})Why
Measured by the zod.dev Zod Mini page (gzip): z.boolean().parse(true) bundles to 2.12 kB with Zod Mini and 5.91 kB with Zod; a three-key object schema bundles to 4.0 kB and 13.1 kB.
| Zod | Zod Mini | |
| Import | import * as z from "zod" | import * as z from "zod/mini", or the @zod/mini package, which tracks zod versions |
| Checks | Methods: .min(8), .trim(), .refine() | .check(z.minLength(8), z.trim(), z.refine(...)) |
| Wrappers | .optional(), .nullable() | z.optional(s), z.nullable(s) |
| Error messages | English locale loaded | None loaded: "Invalid input" until z.config(en()) |
| JSON Schema, metadata | z.toJSONSchema(), .meta(), .describe() | z.toJSONSchema(); z.meta() and z.describe() are checks |
| Cyclic input to recursive schemas | Works | Needs a registered memoizer |
| Use when | Servers, scripts, most front ends | Client bundles with a strict size budget |
A library that must accept both Zod and Zod Mini schemas builds against zod/v4/core (zod.dev/library-authors).
Compile the Final Schema, and Prove It Compiled
perf-compile · MEDIUM · Pass the final schema to z.compile(); a derived schema is not compiled, and an unsupported schema falls back without an error.
Problem
z.compile() (Zod 4.5) makes valid input parse faster on a hot path. It can fail without a sign. A method called on a compiled schema, such as .refine() or .optional(), returns a new schema that is not compiled. An async check or a .catch() function anywhere in the schema makes z.compile() return the original schema, uncompiled. No error tells you.
Incorrect
// BUG: .refine() returns a new schema, and the new schema is not compiled
const Order = z.compile(z.object({ id: z.string(), qty: z.int() })).refine((o) => o.qty > 0)
// BUG: a .catch() function anywhere makes z.compile() return the original schema
const Query = z.compile(z.object({ page: z.int().catch(() => 1) }))Correct
const OrderShape = z.object({ id: z.string(), qty: z.int() }).refine((o) => o.qty > 0)
export const Order = z.compile(OrderShape)
const QueryShape = z.object({ page: z.int().catch(1) }) // a constant .catch() compiles
export const Query = z.compile(QueryShape)
// A test fails when a schema stops compiling
test("hot-path schemas compile", () => {
expect(() => z.compile(OrderShape, { strict: true })).not.toThrow()
expect(() => z.compile(QueryShape, { strict: true })).not.toThrow()
})Why
A compiled schema returns the same data and the same issues as the original, so compiling is safe where it applies. strict: true throws z.ZodCompileUnsupportedError or z.ZodCompileAsyncError instead of falling back, which makes the fall-back visible in a test.
On invalid input Zod runs the standard parser again to build the issues: failures do not get faster, and a refinement can run twice, so keep refinements free of side effects. Use .validate() when you need only a boolean. The compiler adds about 7 KB gzipped to a bundle that uses it. Under a strict Content Security Policy, new Function is blocked; install a parser generated at build time with z.withParser(). See references/compile.md.
6. v4 Migration
Impact: MEDIUM
v4: Top-Level String Formats
migrate-string-formats · MEDIUM · Use top-level z.email(), z.uuid(), z.url() instead of the deprecated z.string().email() methods.
Problem
Zod 4 moved the string formats to top-level functions. The method forms such as z.string().email() still work, and their type definitions mark them deprecated (zod.dev/v4/changelog).
Incorrect
// BAD: method forms from Zod 3
const Email = z.string().email() // deprecated: use z.email()
const Url = z.string().url() // deprecated: use z.url()
const Uuid = z.string().uuid() // deprecated: use z.uuid()
const Cuid = z.cuid() // deprecated: CUID v1 leaks information; use z.cuid2()Correct
const Email = z.email()
const Url = z.url()
const HttpUrl = z.httpUrl() // http or https, with a valid domain
const Uuid = z.uuid() // RFC 9562 variant bits; z.guid() for any 8-4-4-4-12 hex string
const Cuid2 = z.cuid2()
const Ulid = z.ulid()
const Ipv4 = z.ipv4()
const Cidrv6 = z.cidrv6()
const Jwt = z.jwt()
const Base64url = z.base64url() // no padding
const IsoDate = z.iso.date()
const IsoDateTime = z.iso.datetime() // seconds required since Zod 4.5Why
Each format is its own schema type, and the top-level form is shorter and tree-shakes better. z.uuid() checks the RFC 9562 variant bits; it accepts every version unless you pass { version } or use z.uuidv4(), z.uuidv7(). z.string().ip() and z.string().cidr() are removed; use z.ipv4()/z.ipv6() and z.cidrv4()/z.cidrv6(), or a union. The full list is in references/schema-types.md.
v4: Unified z.enum()
migrate-native-enum · MEDIUM · Pass TypeScript enums to z.enum(). z.nativeEnum() still runs but is deprecated.
Problem
Zod 3 had z.enum() for string arrays and z.nativeEnum() for TypeScript enums. In Zod 4, z.enum() takes both. z.nativeEnum() still runs, and its type definition marks it deprecated.
Incorrect
enum Role {
Admin = "admin",
User = "user",
}
const RoleSchema = z.nativeEnum(Role) // deprecated: use z.enum(Role)Correct
enum Role {
Admin = "admin",
User = "user",
}
const RoleSchema = z.enum(Role)
// a string array, written inline or `as const` so the values stay literal types
const StatusSchema = z.enum(["active", "inactive", "pending"])
// .enum gives the enum-like object; .exclude() and .extract() derive new schemas
StatusSchema.enum.active // "active"
const Open = StatusSchema.exclude(["inactive"])Why
z.enum() accepts a string array, a TypeScript enum, or an enum-like object (zod.dev/api, "Enums"). Since Zod 4.6, .options of a numeric enum lists only the values: z.enum(Country).options for enum Country { UK, Germany } is [0, 1], without the reverse-mapping keys. .Enum and .Values from Zod 3 are removed; use .enum.
v4: Unified Error Parameter
migrate-error-api · MEDIUM · Replace message, required_error, invalid_type_error and errorMap with the error parameter.
Problem
Zod 3 had four ways to customize errors: message, required_error, invalid_type_error and errorMap. Zod 4 replaces them with one error parameter. message still works and is deprecated. The other three are gone: TypeScript rejects them, and at runtime Zod ignores them.
Incorrect
// BAD: Zod 3 parameters
const name = z.string({
// @ts-expect-error not a Zod 4 parameter; ignored at runtime
required_error: "Required",
})
// @ts-expect-error errorMap is now `error`; ignored at runtime
const email = z.email({ errorMap: () => ({ message: "Bad email" }) })
const age = z.number().min(18, { message: "Too young" }) // `message` is deprecatedCorrect
// GOOD: a string
const age = z.number().min(18, { error: "Must be 18 or older" })
const ageShort = z.number().min(18, "Must be 18 or older")
// GOOD: a function, which replaces errorMap; it returns a string, or undefined to use the next error map
const email = z.email({
error: (issue) => (issue.input === undefined ? "Email is required" : "Enter a valid email"),
})Why
An error map in Zod 4 returns a plain string instead of { message }. Precedence also changed: a schema-level error now wins over an error map passed to .parse() (zod.dev/v4/changelog). For a global map, use z.config({ customError }).
7. Advanced Patterns
Impact: MEDIUM
Branded Types for Nominal Typing
pattern-branded-types · MEDIUM · Use .brand<"Name">() for nominal typing to prevent mixing structurally identical types.
Problem
TypeScript uses structural typing — two types with the same shape are interchangeable. This means you can accidentally pass a USD value where EUR is expected, or a UserId where a PostId is expected.
Incorrect
// BAD: nothing prevents mixing these up
type USD = number
type EUR = number
function convert(amount: USD, rate: number): EUR {
return amount * rate
}
const priceInEur: EUR = 100
convert(priceInEur, 1.1) // No error! EUR passed as USDCorrect
// GOOD: branded types prevent mixing
const USD = z.number().brand<"USD">()
const EUR = z.number().brand<"EUR">()
type USD = z.infer<typeof USD> // number & z.$brand<"USD">
type EUR = z.infer<typeof EUR> // number & z.$brand<"EUR">
function convert(amount: USD, rate: number): EUR {
return EUR.parse(amount * rate)
}
const priceInEur = EUR.parse(100)
// @ts-expect-error EUR is not assignable to USD
convert(priceInEur, 1.1)Why
.brand() adds a type-only brand, z.$brand<"USD">, that makes structurally identical types incompatible. It changes nothing at runtime: the only way to get a branded value is to parse it. By default only the output type is branded; since Zod 4.2 a second type argument sets the direction: .brand<"Cat", "in">(), "out" (default) or "inout" (zod.dev/api, "Branded types"). Use brands for IDs (UserId vs PostId), currencies, units, or any domain where mixing values is a logic error.
Codecs for Bidirectional Transforms
pattern-codecs · MEDIUM · Use z.codec() for bidirectional transforms (e.g., ISO string ↔ Date). .transform() is one-way.
Problem
.transform() is one-directional — it converts input to output during parsing. But when you need to serialize data back (e.g., Date → ISO string for an API response), a one-way transform loses the encode direction.
Incorrect
// BAD: transform is one-way — can't serialize back to ISO string
const DateField = z.string().transform((s) => new Date(s))
// Parsing works: "2024-01-01" → Date object
// But how do you convert back to "2024-01-01" for the API?Correct
// GOOD: codec defines both directions
const DateField = z.codec(z.iso.datetime(), z.date(), {
decode: (s) => new Date(s), // string → Date (parsing)
encode: (d) => d.toISOString(), // Date → string (serialization)
})
const parsed = DateField.parse("2024-01-01T00:00:00Z") // Date object
const serialized = DateField.encode(parsed) // "2024-01-01T00:00:00.000Z"Why
Codecs define a decode (parse direction) and encode (serialize direction) pair, for values that round-trip between representations: API input and output, database rows, form values. .parse() takes unknown; .decode() and .encode() take typed input, so TypeScript catches a wrong argument. Calling .encode() on a schema that contains a .transform() throws $ZodEncodeError, a plain error rather than a ZodError. Defaults, prefaults and .catch() apply only when decoding. The zod.dev codecs page has copy-in recipes such as stringToInt, epochSecondsToDate and jsonCodec; Zod does not export them.
.transform() | z.codec() | |
| Direction | One-way (input → output) | Bidirectional (decode + encode) |
| Use when | Only parsing (API input) | Round-trip (API input ↔ output) |
| Encoding | Throws $ZodEncodeError | schema.encode(value) |
Pipe for Staged Parsing
pattern-pipe · MEDIUM · Use .pipe() to chain validation stages. Parse string → coerce number → validate range.
Problem
Complex validation chains that coerce and validate in multiple stages become messy when done in a single transform. Each stage should parse the output of the previous stage.
Incorrect
// BAD: everything in one transform — no intermediate validation
const PortNumber = z.string().transform((val) => {
const n = parseInt(val, 10)
if (isNaN(n)) throw new Error("Not a number") // wrong: shouldn't throw
if (n < 1 || n > 65535) throw new Error("Invalid port") // wrong: shouldn't throw
return n
})Correct
// GOOD: staged parsing with pipe
const PortNumber = z
.string()
.pipe(z.coerce.number()) // stage 1: string → number
.pipe(z.int().min(1).max(65535)) // stage 2: validate range
PortNumber.parse("8080") // 8080
PortNumber.safeParse("abc").success // false: stage 1 fails
PortNumber.safeParse("99999").success // false: stage 2 failsWhy
.pipe() feeds the output of one schema into another. Each stage produces Zod issues on failure, and each stage's type is inferred. z.coerce.number() accepts whatever Number() accepts, so "0x50" becomes 80, "1e3" becomes 1000 and " 80 " becomes 80. When only decimal digits are valid, check the string first: z.string().regex(z.regexes.integer).transform(Number).pipe(z.int().min(1).max(65535)).
8. Architecture & Boundaries
Impact: CRITICAL/HIGH
Parse at System Boundaries, Not in Domain Logic
arch-boundary-parsing · CRITICAL · Call safeParse() at entry points (API handlers, env startup, form resolvers, external fetches) and pass typed data inward. Domain logic receives typed values, not unknown.
Problem
When safeParse() calls are scattered throughout domain logic, every function must handle unknown input and validation errors. This mixes parsing concerns with business logic, makes error handling inconsistent, and creates redundant validation.
Incorrect
const OrderSchema = z.object({ id: z.string(), total: z.number().nonnegative() })
// BUG: domain logic handles unknown input and parsing
function calculateDiscount(data: unknown) {
const result = OrderSchema.safeParse(data)
if (!result.success) {
throw new Error("Invalid order")
}
const order = result.data
if (order.total > 100) {
return order.total * 0.1
}
return 0
}
// BUG: service layer re-parses what was already validated
function processOrder(data: unknown) {
const result = OrderSchema.safeParse(data)
if (!result.success) return { error: "Invalid" }
const discount = calculateDiscount(data) // parses again!
return { total: result.data.total - discount }
}Correct
// Parse ONCE at the boundary
app.post("/orders", (req, res) => {
const result = OrderSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
errors: z.flattenError(result.error).fieldErrors,
})
}
// Pass typed data inward — no more unknown
const response = processOrder(result.data)
res.json(response)
})
// Domain logic receives typed data — no parsing needed
function calculateDiscount(order: Order): number {
return order.total > 100 ? order.total * 0.1 : 0
}
function processOrder(order: Order) {
const discount = calculateDiscount(order)
return { total: order.total - discount }
}
type Order = z.infer<typeof OrderSchema>Decision Table: Where to Parse
| Boundary | Where to call safeParse() |
| Express/Fastify route | In the route handler, before calling service functions |
| tRPC | .input(Schema) — framework parses for you |
| Next.js Server Action | At the top of the action function, before any logic |
| React Hook Form | zodResolver(Schema) — resolver parses on submit |
| Environment variables | At app startup (e.g., envSchema.parse(process.env)) |
| External API response | Immediately after fetch(), before using the data |
| Database results | After query, if schema might drift from DB shape |
| Message queue consumer | At the top of the handler, before processing |
Why
Parsing at the boundary means domain functions are pure — they accept typed values and return typed values. This eliminates redundant validation, centralizes error handling, and makes business logic easier to test (no need to construct invalid inputs). The boundary layer is the only place that deals with unknown data.
Co-locate Schemas with Their Boundary Layer
arch-schema-organization · HIGH · Place schemas next to the boundary that uses them. API schemas in api/users/schemas.ts, form schemas in features/profile/form-schema.ts. Domain types use z.infer, never re-export raw schemas across layers.
Problem
Dumping all schemas into a single schemas/ folder creates a grab bag of unrelated schemas with no indication of where they're used. Schemas for API routes, form validation, and env parsing all mix together, making it unclear which boundary owns which schema.
Incorrect
# BUG: everything in one folder — no layering, unclear ownership
src/
schemas/
user.ts # used by API AND form AND env?
order.ts
config.ts
form-profile.ts
api-response.ts
db-result.ts// BUG: domain module imports and re-exports raw schemas
// src/domain/user.ts
import { UserSchema } from "../schemas/user"
export { UserSchema } // leaking parsing into domainCorrect
src/
api/
users/
route.ts
schemas.ts # API request/response schemas for /users
orders/
route.ts
schemas.ts # API schemas for /orders
features/
profile/
form-schema.ts # form validation schema
ProfileForm.tsx
config/
env.ts # env var schema, parsed at startup
domain/
types.ts # z.infer types only — no raw schemas// api/users/schemas.ts — co-located with the route
import * as z from "zod"
export const CreateUserBody = z.object({
name: z.string().min(1),
email: z.email(),
})
export const UserResponse = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
})// domain/types.ts — inferred types only, no raw schemas
import type { CreateUserBody, UserResponse } from "../api/users/schemas"
import type * as z from "zod"
export type CreateUser = z.infer<typeof CreateUserBody>
export type User = z.infer<typeof UserResponse>Why
Co-location makes ownership clear: the API route handler and its schemas live in the same directory. When a route changes, the schema changes with it. Domain types are derived via z.infer so the domain layer never depends on Zod directly — only on TypeScript types. This keeps parsing at the boundary and business logic free of validation concerns.
Evolve Schemas Without Breaking Consumers
arch-schema-versioning · MEDIUM · Use additive changes for non-breaking evolution. New fields use .optional(). Never remove required fields without a major version bump.
Problem
Removing or tightening fields in a Zod schema silently breaks consumers. Because z.object() strips unknown keys by default, removed fields vanish from parsed output without any error — consumers reading that field get undefined at runtime despite TypeScript showing no compile error if they haven't updated their types.
Incorrect
// v1 — consumers depend on `role`
const UserV1 = z.object({
name: z.string(),
email: z.email(),
role: z.enum(["admin", "user"]),
nickname: z.string(),
})
// v2 — BREAKING: removed `role`, consumers silently get undefined
const UserV2 = z.object({
name: z.string(),
email: z.email(),
nickname: z.string(),
// role is gone — consumers calling user.role get undefined
})Correct
// v2 — non-breaking: keep role, add displayName as optional, deprecate nickname
const UserV2 = z.object({
name: z.string(),
email: z.email(),
role: z.enum(["admin", "user"]), // kept — not removed
nickname: z.string(), // kept — deprecate in docs, remove in v3
displayName: z.string().optional(), // new — optional so old payloads still parse
})
type UserV2 = z.infer<typeof UserV2>Decision Table: Schema Changes
| Change | Breaking? | Safe Approach |
| Add optional field | No | .optional() — old data still parses |
| Add required field | Yes | Make optional first, require in next major |
| Remove field | Yes | Deprecate first, remove in next major |
| Tighten constraint (e.g., min 1 → min 5) | Yes | Previously valid data now fails |
| Loosen constraint (e.g., min 5 → min 1) | No | All existing data still valid |
| Rename field | Yes | Add new name as optional, keep old, migrate |
| Change type (e.g., string → number) | Yes | New field with new name, deprecate old |
| Add union member | No | Existing data still matches |
| Remove union member | Yes | Existing data with that value fails |
Why
Zod schemas are runtime contracts. Changing them has the same impact as changing an API — consumers that depend on the shape will break. Additive changes (new optional fields, loosened constraints, new union members) are always safe. Subtractive changes (removing fields, tightening constraints) require coordinated migration across all consumers.
9. Observability
Impact: HIGH/MEDIUM
Log Structured Errors, Not Raw ZodError
observe-structured-errors · HIGH · Use z.flattenError() for compact, structured logs with request correlation IDs. Never console.log a raw ZodError — it's noisy, unstructured, and impossible to query.
Problem
Raw ZodError objects contain deeply nested issue arrays with internal metadata. Logging them directly produces noisy, unstructured output that's impossible to filter, alert on, or query in log aggregation tools (Datadog, Grafana, ELK).
Incorrect
const UserSchema = z.object({ name: z.string(), email: z.email() })
// BUG: raw ZodError is noisy and unqueryable
app.post("/api/users", (req, res) => {
const result = UserSchema.safeParse(req.body)
if (!result.success) {
logger.error("Validation failed", { error: result.error })
// Logs a massive nested object with internal Zod metadata
// Cannot search by field name, schema, or request
return res.status(400).json({ error: "Invalid input" })
}
})// BUG: console.log — no structure, no correlation
const result = UserSchema.safeParse(data)
if (!result.success) {
console.log("Validation failed:", result.error)
}Correct
app.post("/api/users", (req, res) => {
const result = UserSchema.safeParse(req.body)
if (!result.success) {
const flat = z.flattenError(result.error)
logger.warn("validation_failed", {
requestId: req.get("x-request-id"),
schema: "UserSchema",
path: req.path,
formErrors: flat.formErrors,
fieldErrors: flat.fieldErrors,
})
return res.status(400).json({ errors: flat.fieldErrors })
}
})// Reusable helper for consistent structured logging
type Logger = { warn(event: string, fields: Record<string, unknown>): void }
function logValidationError(
logger: Logger,
opts: {
requestId: string
schema: string
error: z.ZodError
path?: string
}
) {
const flat = z.flattenError(opts.error)
logger.warn("validation_failed", {
requestId: opts.requestId,
schema: opts.schema,
path: opts.path,
formErrors: flat.formErrors,
fieldErrors: flat.fieldErrors,
fieldCount: Object.keys(flat.fieldErrors).length,
})
}Why
Structured logs with z.flattenError() are compact (field name → error messages), queryable (search by schema name or failing field), and correlatable (request ID ties the error to a specific request). This enables dashboards that show which schemas and fields fail most often, alerting on validation spike rates, and debugging specific user requests.
Track Validation Error Rates per Schema and Field
observe-error-metrics · MEDIUM · Wrap safeParse in a tracked helper that increments counters per schema and per field on failure. Identify too-strict schemas (high failure rate) and too-loose schemas (never fail).
Problem
Without metrics on validation failures, you have no visibility into schema behavior in production. Too-strict schemas silently reject valid user input (causing UX frustration), while too-loose schemas that never fail may not be providing any real protection.
Incorrect
const OrderSchema = z.object({ sku: z.string(), quantity: z.int().positive() })
// No monitoring — validation is a black box
app.post("/api/orders", (req, res) => {
const result = OrderSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ error: "Invalid" })
}
// How often does this fail? Which fields? No one knows.
})Correct
import * as z from "zod"
const OrderSchema = z.object({ sku: z.string(), quantity: z.int().positive() })
// Tracked safeParse wrapper
function trackedSafeParse<T extends z.ZodType>(
schema: T,
data: unknown,
schemaName: string
): z.ZodSafeParseResult<z.output<T>> {
const result = schema.safeParse(data)
metrics.increment("zod.validation.attempt", { schema: schemaName })
if (!result.success) {
metrics.increment("zod.validation.failure", { schema: schemaName })
// Track which fields fail
const flat = z.flattenError(result.error)
for (const field of Object.keys(flat.fieldErrors)) {
metrics.increment("zod.validation.field_error", {
schema: schemaName,
field,
})
}
}
return result
}
function processOrder(order: z.infer<typeof OrderSchema>) {
return { accepted: order.quantity }
}
// Usage
app.post("/api/orders", (req, res) => {
const result = trackedSafeParse(OrderSchema, req.body, "OrderSchema")
if (!result.success) {
return res.status(400).json({
errors: z.flattenError(result.error).fieldErrors,
})
}
res.json(processOrder(result.data))
})What the Metrics Tell You
| Signal | Meaning | Action |
| High failure rate on a schema | Schema may be too strict | Review constraints, check if users send valid data that gets rejected |
| One field dominates failures | Confusing input format | Improve form UX, add coercion, clarify docs |
| Schema never fails | Schema may be too loose | Review if it's actually validating anything meaningful |
| Failure spike after deploy | Schema change broke clients | Roll back or make the change additive |
Why
Validation metrics close the feedback loop between schema design and real-world usage. Without them, you're guessing whether schemas are helping or hurting. Tracking per-schema and per-field failure rates turns Zod from a silent gatekeeper into an observable system component.
References
The references are not expanded here. Read them from the skill folder:
references/anti-patterns.mdreferences/behaviour-changes.mdreferences/boundary-architecture.mdreferences/codecs.mdreferences/compile.mdreferences/error-handling.mdreferences/linter-and-ci.mdreferences/metadata-and-json-schema.mdreferences/objects-and-composition.mdreferences/parsing-and-inference.mdreferences/refinements-and-transforms.mdreferences/schema-types.mdreferences/testing-anti-patterns.mdreferences/testing-api.mdreferences/testing.md
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

