Skip to main content

Transforming and Refining Data with Zod superRefine

Symptom: a schema validates each field correctly and still lets through a payload where ends_at precedes starts_at, or where a discount exceeds the order total. Single-field checks cannot see other fields — cross-field rules need superRefine. This guide is part of Runtime Validation with Zod.

Root Cause: Field Checks Cannot See Siblings

z.string().min(1) sees one value. A rule involving two fields has to be attached to the object that contains both, and it has to run after the fields themselves have parsed.

What runs when Field-level parsing runs first and can fail the whole parse; only if every field parses do object-level refinements run, and transforms run last, changing the output type. 1. field parse types, formats, ranges per field, independently 2. superRefine sees the whole object adds any number of issues 3. transform changes the value and the output type output a failure here stops the parse — step 2 never runs a refinement written after step 3 validates the transformed shape

Step 1: Attach the Rule to the Object

import { z } from "zod";

export const bookingSchema = z.object({
  starts_at: z.string().datetime({ offset: true }),
  ends_at: z.string().datetime({ offset: true }),
  guests: z.number().int().min(1).max(20),
  total_minor: z.number().int().min(0),
  discount_minor: z.number().int().min(0).default(0),
}).superRefine((booking, ctx) => {
  if (Date.parse(booking.ends_at) <= Date.parse(booking.starts_at)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["ends_at"],                      // lands on ONE input, not the object
      message: "must be after starts_at",
    });
  }

  if (booking.discount_minor > booking.total_minor) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["discount_minor"],
      message: "cannot exceed the order total",
    });
  }

  const nights = (Date.parse(booking.ends_at) - Date.parse(booking.starts_at)) / 86_400_000;
  if (nights > 30) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["ends_at"],
      message: "a booking cannot exceed 30 nights",
    });
  }
});

Why path is not optional in practice: without it, every cross-field issue attaches to the object root, and a form receives three errors it cannot place next to any input. With it, the issue maps directly onto a field name and into the field-level error contract the API already publishes.

superRefine also reports all violations in one pass, which refine cannot — a caller fixing three problems gets three messages rather than three round trips.

Step 2: Mark Blocking Issues Fatal

When one failure makes later checks meaningless, say so. Otherwise the response carries a cascade of secondary errors derived from data you already rejected.

const transferSchema = z.object({
  from_account: z.string(),
  to_account: z.string(),
  amount_minor: z.number().int().positive(),
}).superRefine((t, ctx) => {
  if (t.from_account === t.to_account) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["to_account"],
      message: "must differ from from_account",
      fatal: true,                     // nothing below this is worth checking
    });
    return z.NEVER;                    // stops the remaining refinements
  }

  // Only reached when the accounts differ.
  if (!isSameCurrency(t.from_account, t.to_account) && t.amount_minor > 100_000_00) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["amount_minor"],
      message: "cross-currency transfers are limited to 100,000.00",
    });
  }
});

Step 3: Put Transforms After Validation, Never Before

A refinement placed after .transform() validates the output of the transform. That is legitimate when you mean it, and a bug when you do not — the rule silently starts checking a shape the client never sent.

// WRONG — the refinement receives a Date, but the message talks about the string.
const wrong = z.string()
  .transform((s) => new Date(s))
  .refine((d) => !Number.isNaN(d.getTime()), "must be an ISO 8601 timestamp");
// An unparseable string produces an Invalid Date and a message about ISO 8601
// attached to a value that is no longer a string.

// RIGHT — validate the incoming shape, then transform.
const right = z.string()
  .datetime({ offset: true })                 // rejects bad input, with a real message
  .transform((s) => new Date(s));             // only well-formed values get here

The ordering rule generalises: validate the shape you receive, transform once, and do not refine after unless the rule genuinely concerns the transformed value.

Two types, two places a rule can live Before the transform the value is the request shape a string, and rules there describe what the client sent. After the transform it is the internal shape a Date, and rules there describe the parsed value. input type: string what the client actually sent rules here talk to the client .transform() one direction only output type: Date what your code works with rules here talk to your code z.input<typeof schema> and z.infer<typeof schema> differ once a transform exists — use z.input for anything describing the request.

Step 4: Flatten Issues Into the Error Contract

// errors.ts — one mapping, used by every route
import { ZodError } from "zod";

export function toFieldErrors(error: ZodError) {
  return error.issues.map((issue) => ({
    // The path array becomes a dotted field name the client can match to an input.
    path: issue.path.join(".") || "_root",
    code: issue.code === "custom" ? "INVALID_VALUE" : issue.code.toUpperCase(),
    message: issue.message,
  }));
}
Two rules, two placeable errors Two cross-field violations detected in one parse become two entries in the errors array, each carrying the path of the field the user should change rather than the object as a whole. ends_at before starts_at discount over the total one parse both issues, with paths errors[0].path = "ends_at" errors[1].path = "discount_minor" One round trip, and each message lands on the input the user must change.
{
  "type": "https://docs.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "code": "VALIDATION_FAILED",
  "errors": [
    { "path": "ends_at", "code": "INVALID_VALUE", "message": "must be after starts_at" },
    { "path": "discount_minor", "code": "INVALID_VALUE", "message": "cannot exceed the order total" }
  ]
}

Both cross-field rules arrive as separate, placeable errors — which is the entire practical payoff of setting path on every issue.

Verification

test("reports every cross-field violation in one parse", () => {
  const result = bookingSchema.safeParse({
    starts_at: "2026-08-10T10:00:00Z",
    ends_at: "2026-08-09T10:00:00Z",       // before the start
    guests: 2,
    total_minor: 5000,
    discount_minor: 9000,                   // more than the total
  });
  expect(result.success).toBe(false);
  const paths = result.error!.issues.map((i) => i.path.join("."));
  expect(paths).toEqual(expect.arrayContaining(["ends_at", "discount_minor"]));
  expect(result.error!.issues).toHaveLength(2);        // not one, not five
});

test("refinements do not run when a field fails to parse", () => {
  const result = bookingSchema.safeParse({ starts_at: "not-a-date", ends_at: "also-not",
                                           guests: 2, total_minor: 1 });
  expect(result.success).toBe(false);
  // Only the two datetime failures — no confusing "must be after starts_at".
  expect(result.error!.issues.every((i) => i.code === "invalid_string")).toBe(true);
});

test("a fatal issue stops later refinements", () => {
  const result = transferSchema.safeParse({ from_account: "acc_1", to_account: "acc_1",
                                            amount_minor: 50_000_000 });
  expect(result.error!.issues).toHaveLength(1);        // not also the limit error
  expect(result.error!.issues[0].path).toEqual(["to_account"]);
});

test("input and output types differ after a transform", () => {
  const parsed = right.parse("2026-08-10T10:00:00Z");
  expect(parsed).toBeInstanceOf(Date);
  expect(() => right.parse("not-a-date")).toThrow(/datetime/);   // message names the input rule
});

Edge Cases and Caveats

  • .default() runs before refinements. A field with a default is populated by the time superRefine sees it, so a rule checking “was this supplied?” cannot distinguish a default from a real value. Use .optional() and check for undefined when the distinction matters.
  • Async rules. Uniqueness checks against a database need superRefine on an async schema and parseAsync. Calling parse on a schema with an async refinement throws rather than silently skipping — which is the right behaviour, and a common surprise when migrating from libraries where it does not.
  • .strict() and unknown keys. Unknown-key errors are raised before refinements, so a payload with a typo reports the typo and not your cross-field rule. That ordering is correct but worth knowing when a test seems to check the wrong thing.
  • Discriminated unions. A refinement on a union member runs only when that branch is selected. If the rule spans branches, put it on the union rather than inside one, and check the discriminated union rules first — a union that never resolves never refines.
  • Schema-side equivalence. The same rule expressed in JSON Schema is an if/then block, with its own vacuous-truth hazard described in conditional schemas with if, then and else. Keeping both in step is worth a shared test fixture.

Keeping Refinements Testable

A schema with several cross-field rules becomes hard to reason about, and the usual symptom is a test suite that asserts on error counts rather than on specific issues. Two habits keep it manageable.

Extract each rule into a named function taking the parsed value and the context. bookingDatesAreOrdered(booking, ctx) reads better than an anonymous block, can be unit-tested in isolation, and makes the superRefine body a list of rule names — which is the clearest documentation of what the object requires.

Assert on paths and codes, never on message text. A test matching a message string breaks when you improve the wording, which trains people to stop improving it. Matching path and a stable code keeps the test meaningful and the messages editable.

When the Rule Belongs Somewhere Else

Not every cross-field rule belongs in the schema. Three signals suggest moving it into the service layer instead: the rule needs data the request does not contain, such as the current state of a resource; the rule depends on who is calling, which the schema cannot see; or the rule is expensive, such as a database lookup, in which case running it before cheaper checks wastes work on requests that were going to fail anyway.

The dividing line is whether the rule is a statement about the payload or about the world. Payload rules — this field must be after that one, this total must cover this discount — belong in the schema, where they are declarative and testable. World rules belong in the service, where the world is visible.

A Note on Performance

Refinements run after a successful parse, so they cost nothing on requests that fail earlier. Where a refinement is expensive — a database lookup for uniqueness — order it last and mark cheaper checks fatal, so an obviously invalid request never reaches it.

Frequently Asked Questions

When should I use superRefine rather than refine?

Use refine for a single yes-or-no check on one value. Use superRefine when you need to add more than one issue, set a path so the error lands on a specific field, or choose the issue code — which is almost always what an API needs.

Why does my refinement not run?

Because an earlier check failed. Zod stops before refinements when the base parse fails, so a missing required field means your cross-field rule never executes. That is usually correct — refining values you do not have produces confusing errors.

Can a refinement change the value?

No. Refinements only report issues; transforms change values. Mixing the two intentions in one step is the most common source of surprise, which is why the order of operations matters.

Why does a refinement after a transform see the wrong shape?

Because it validates the output of the transform, not the input. A refinement placed after .transform() checks the new shape, so a rule about the request must go before it.

How do I stop later checks running after a fatal issue?

Pass fatal: true when adding the issue and return z.NEVER. Without it, subsequent refinements run against data you have already rejected, producing cascades of secondary errors.