Capítulo 25 de 61

Chapter 25: Refinements

Core Idea

.refine() attaches a custom validation function to a schema for logic Zod has no built-in check for; it never changes the inferred type, only adds a pass/fail (and optional message) on top.

Key Concepts

  • .refine(fn) / z.refine(fn) (Mini, inside .check()): fn receives the parsed value and must return a truthy/falsy result — never throw, since thrown errors aren't caught by Zod.
  • error option: a static string, or a function receiving the issue ((iss) => ...) for a dynamic message that can reference the failing input.
  • abort option: by default a failing refinement is continuable — Zod keeps running subsequent checks and collects all issues. Setting abort: true makes that refinement short-circuit validation on failure.
  • path option: overrides where the resulting issue is attached in the error tree — typically used on object-level refinements to point the error at a specific field (e.g. a "confirm password" field) rather than the whole object.

Code Examples

const myString = z.string().refine((val) => val.length <= 255);

const myString2 = z.string().refine((val) => val.length > 8, {
  error: (iss) => `Too short: "${iss.input}"`,
});

// path: attach a cross-field error to a specific key
const passwordForm = z
  .object({ password: z.string(), confirm: z.string() })
  .refine((data) => data.password === data.confirm, {
    error: "Passwords don't match",
    path: ["confirm"],
  });

// abort: stop at the first failing refinement instead of collecting all issues
const strict = z.string()
  .refine((val) => val.length > 8, { error: "Too short!", abort: true })
  .refine((val) => val === val.toLowerCase(), { error: "Must be lowercase", abort: true });
  • What it demonstrates: message customization, cross-field error placement via path, and controlling continue-vs-abort behavior across chained refinements.

Anti-patterns

  • Throwing inside a refinement function: Zod does not catch thrown errors from refinements — return false/falsy instead, always.
  • Forgetting path on an object-level refinement: without it, the error attaches to the whole object rather than the specific field the user should fix (e.g. a mismatched "confirm" field).

Key Takeaways

  1. Refinements are purely additive validation — they can reject a value but never widen or narrow the inferred TypeScript type.
  2. By default, multiple failing refinements all surface as separate issues (continuable); use abort: true only when a later check genuinely doesn't make sense after an earlier one fails.
  3. path is the mechanism for pointing a cross-field validation error at the specific field that should show it in a form.

Connects To

  • Pipes & Transforms: refinements validate; transforms and pipes reshape — the two compose but serve different purposes.
  • Objects — Extending & Deriving: .extend() throws on refined schemas; .safeExtend() preserves refinements when extending.