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.
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.
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,
}));
}
{
"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 timesuperRefinesees it, so a rule checking “was this supplied?” cannot distinguish a default from a real value. Use.optional()and check forundefinedwhen the distinction matters.- Async rules. Uniqueness checks against a database need
superRefineon an async schema andparseAsync. Callingparseon 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/thenblock, 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.