agentleFS
anivaranivar/zod-skill/AGENTS.md

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.

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
  1. Very specific:

    Every rule in rules/, expanded, for agents that read one file.

    line 5
  2. Very specific:

    When a schema contains async refinements (.refine(async ...)) or async transforms (.transform(async ...)), calling synchronous .parse() or …

    line 77
  3. Very specific:

    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).

    line 157
  4. Distinctive:

    When TypeScript reports TS7023 ("implicitly has return type 'any'") on a getter, annotate the getter's return type:

    line 396
  5. Very specific:

    When the value must also go back to a string, use a codec instead (see pattern-codecs).

    line 554
  6. Very specific:

    When safeParse() calls are scattered throughout domain logic, every function must handle unknown input and validation errors.

    line 1237

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md
<!-- 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

  1. Parsing & Type Safety — CRITICAL
  2. Schema Design — CRITICAL
  3. Refinements & Transforms — HIGH
  4. Error Handling — HIGH
  5. Performance & Composition — MEDIUM
  6. v4 Migration — MEDIUM
  7. Advanced Patterns — MEDIUM
  8. Architecture & Boundaries — CRITICAL/HIGH
  9. 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 updated

Correct

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 gone

Correct

// 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

VariantUnknown keysUse when
z.object()StripsSanitizing user input, you want only known fields
z.strictObject()Rejects (error)API contracts, config validation — no surprises
z.looseObject()PreservesProxying 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 issues

Correct

// 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 // false

When 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

FunctionOutputUse case
z.flattenError(){ formErrors, fieldErrors }One-level forms; nested issues land under the top-level key
z.treeifyError(){ errors, properties?, items? }, nested like the schemaNested forms, arrays, recursive schemas
z.prettifyError()A multi-line stringLogs, 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 resolve

Correct

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 BaseKeptLost: you choose z.object, z.strictObject or z.looseObject
Refinements on BaseKept; throws if a new key overwrites an existing oneLost: .shape holds only the fields
Overwrite a key on a refined BaseUse .safeExtend(), which also checks the new schema is assignableAllowed

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.

ZodZod Mini
Importimport * as z from "zod"import * as z from "zod/mini", or the @zod/mini package, which tracks zod versions
ChecksMethods: .min(8), .trim(), .refine().check(z.minLength(8), z.trim(), z.refine(...))
Wrappers.optional(), .nullable()z.optional(s), z.nullable(s)
Error messagesEnglish locale loadedNone loaded: "Invalid input" until z.config(en())
JSON Schema, metadataz.toJSONSchema(), .meta(), .describe()z.toJSONSchema(); z.meta() and z.describe() are checks
Cyclic input to recursive schemasWorksNeeds a registered memoizer
Use whenServers, scripts, most front endsClient 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.5

Why

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 deprecated

Correct

// 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 USD

Correct

// 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()
DirectionOne-way (input → output)Bidirectional (decode + encode)
Use whenOnly parsing (API input)Round-trip (API input ↔ output)
EncodingThrows $ZodEncodeErrorschema.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 fails

Why

.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

BoundaryWhere to call safeParse()
Express/Fastify routeIn the route handler, before calling service functions
tRPC.input(Schema) — framework parses for you
Next.js Server ActionAt the top of the action function, before any logic
React Hook FormzodResolver(Schema) — resolver parses on submit
Environment variablesAt app startup (e.g., envSchema.parse(process.env))
External API responseImmediately after fetch(), before using the data
Database resultsAfter query, if schema might drift from DB shape
Message queue consumerAt 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 domain

Correct

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

ChangeBreaking?Safe Approach
Add optional fieldNo.optional() — old data still parses
Add required fieldYesMake optional first, require in next major
Remove fieldYesDeprecate first, remove in next major
Tighten constraint (e.g., min 1 → min 5)YesPreviously valid data now fails
Loosen constraint (e.g., min 5 → min 1)NoAll existing data still valid
Rename fieldYesAdd new name as optional, keep old, migrate
Change type (e.g., string → number)YesNew field with new name, deprecate old
Add union memberNoExisting data still matches
Remove union memberYesExisting 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

SignalMeaningAction
High failure rate on a schemaSchema may be too strictReview constraints, check if users send valid data that gets rejected
One field dominates failuresConfusing input formatImprove form UX, add coercion, clarify docs
Schema never failsSchema may be too looseReview if it's actually validating anything meaningful
Failure spike after deploySchema change broke clientsRoll 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.md
  • references/behaviour-changes.md
  • references/boundary-architecture.md
  • references/codecs.md
  • references/compile.md
  • references/error-handling.md
  • references/linter-and-ci.md
  • references/metadata-and-json-schema.md
  • references/objects-and-composition.md
  • references/parsing-and-inference.md
  • references/refinements-and-transforms.md
  • references/schema-types.md
  • references/testing-anti-patterns.md
  • references/testing-api.md
  • references/testing.md

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.