Checks and transforms
Zod 4 refinements and transforms, the two extension points for writing custom validation rules and reshaping parsed values that the built-in schemas can't express.
Your signupSchema is almost done. The password field reads z.string().min(8), a string of at least eight characters, which is a real constraint you already have from the last lesson. But put the schema next to an actual sign-up form and you’ll see what it can’t say. The password has to match the confirmation field the user typed twice. It shouldn’t be the user’s email verbatim. And the email you store should be lowercased and trimmed, not whatever casing and stray spaces the user happened to submit.
Those three rules have something in common: there’s no builder for any of them. z.email(), z.uuid(), .min(8), every check you’ve met so far comes from a catalog. You pick the named one that fits and the validation is written. But “this field equals that other field” isn’t in the catalog, and neither is “lowercase whatever comes in.” The invoice has the same kind of problem: the last lesson made dueAt a valid z.iso.datetime(), but the business rule is that dueAt must fall after issuedAt. That’s a relationship between two fields, and no format builder can see it, because a format builder only ever looks at one value at a time.
So the gap is this: every check up to now has been named and prebuilt, but these rules are yours to write. Zod 4 gives you exactly two extension points for writing them. Refinements let you write your own pass/fail check, your own answer to “is this value acceptable?” Transforms let you reshape the value, your answer to “what should this value become?” By the end of this lesson you’ll write single-field and cross-field custom rules, attach their error messages to the right form field, and reshape parsed values: normalize a string, turn an ISO string into a real Date. At each step, you’ll know which of the two tools the job needs.
Choosing the right tool is what this lesson turns on, and a single question makes the choice for you:
Does this rule judge the value, or change it?
If it judges the value, passing or failing but leaving the value alone, reach for a refinement. If it changes the value, producing something new, reach for a transform. Two jobs, two tools. Confusing them is the most common way this goes wrong, so keep the question in mind; you’ll ask it at every turn.
A schema carries a list of checks
Section titled “A schema carries a list of checks”Get the mental model right before any syntax, because the rest of the lesson builds on it.
A Zod schema isn’t a single yes-or-no validation. It’s a pipeline. When a value goes in, it runs through a sequence: first the base type check (is this even a string?), then any constraints you chained (the .min, the .max from the last lesson), then any refinements you added, then any transforms. Each stage hands the value to the next. Take z.string().min(8).refine((p) => !p.includes(' ')), which reads as three stages in order: is it a string, then is it at least eight characters, then your check, no spaces. Three stages, and the value coming out the end is still a string.
That last point carries a lot of weight, so hold onto it. Refinements are checks: predicates that return pass or fail and never touch the value or its type. Transforms are functions: they produce a new value and can change the inferred output type. The routing question maps straight onto that split: a refinement judges, a transform changes. The rest of the lesson is those two tools at different sizes.
One piece of history is worth naming, because you’ll trip over it in older code and in anything an AI trained on older docs hands you. In Zod 3, calling .refine() wrapped your schema in an outer object, a ZodEffects: a different type that sat around the schema like a shell. In Zod 4, a refinement is stored as an entry in a checks array on the schema itself. You don’t need the old mechanics, but it helps to see what the change buys you and what it leaves alone:
- It matters because refinements now compose cleanly. Since the check lives on the schema rather than in a wrapper around it, your custom rules interleave with the built-in constraints, and they survive the derivation methods you’ll meet in the next lesson (
.pick,.omit,.extend) with no shell to unwrap first. In v3 you couldn’t freely mix a.refine()in with a.min(); v4 fixes exactly that. - It does not change the syntax you write. The call site is still
.refine(predicate, options), identical to v3. So unlike thez.string().email()migration from the last lesson, there’s no before-and-after rewrite to learn here. When older docs describe a wrapper, read that as stale framing, not stale code: the way you call.refineis the same, and only what it does under the hood changed.
Carry one line into everything that follows: a refinement does not change the inferred type. A z.string().refine(...) still infers as string. That sounds small, but it’s the sharpest difference between a refinement and a transform, which does change the type. It pays off twice: once when you watch the inferred type not move in an exercise, and again when you watch it move for a transform a few sections later.
.refine for a rule the built-ins miss
Section titled “.refine for a rule the built-ins miss”Start with the simplest case: a rule about a single field. Keep your attention on the mechanic, with no cross-field complication yet.
Take a password that can’t contain spaces. There’s no .noSpaces() builder, so it’s a rule you have to write yourself, and .refine is how you write it:
const passwordSchema = z .string() .min(8) .refine((value) => !value.includes(' '), { error: 'Password cannot contain spaces', });Four things are happening here, and each one is a place beginners stumble.
The predicate returns true when the value passes. Read it again: (value) => !value.includes(' ') is true exactly when there are no spaces, when the password is good. People invert this convention constantly. The instinct is to describe the problem (“it includes a space, that’s bad”) and write value.includes(' '), which returns true for the broken case, so it accepts every password with a space and rejects every clean one. Fix the convention in your mind: in a refinement, true means acceptable. You describe the passing state, not the failing one.
The error option authors the message. When the predicate returns false, the parse produces an issue, and error: 'Password cannot contain spaces' is the text that issue carries. In Zod 4 this is the single unified error parameter. Zod 3 used a separate message key, so you’ll see message: in older code; it’s legacy, and error is the form you write now. A static string is the common case and all you need today. The error param can also take a function (error: (issue) => ...) for dynamic messages. The full machinery around error messages is the subject two lessons from now; here, a static string is plenty, with one taste of the function form later.
The refinement runs only after the base checks pass. The pipeline runs in order: if the input isn’t a string at all, the z.string() check fails first and your predicate never runs. If it’s a string but shorter than eight characters, .min(8) fails, and again the value reaches your refinement only when the earlier stages let it through. The practical upshot is that inside your predicate, value is already a validated string, so you don’t need to defensively check its type. The pipeline guaranteed it.
The inferred type is unchanged. Once more: passwordSchema is still inferred as string. You added a runtime check; you did not touch the type. This is the framing section’s promise, now made concrete.
That’s the whole tool for the single-field case. Hold it this way: .refine is your reach for any rule a built-in doesn’t cover that a single field can answer on its own. It’s pure, value-only validation, which, as you’ll see at the end of the lesson, is exactly the kind of rule that belongs inside a schema.
Cross-field rules and the path that anchors the error
Section titled “Cross-field rules and the path that anchors the error”Now the rule that started the lesson: password has to equal its confirmation. You’ll reuse this pattern in nearly every form you ever build, and it’s also where people ship broken forms, so slow down here.
The rule references two fields. That rules out putting it on either field’s schema, because a check on password only ever sees the password; it has no way to reach over and read confirm. A rule that needs both fields has to attach somewhere that can see both, which is the object schema itself. You call .refine on the whole object, and your predicate receives the entire parsed object, so it can compare across the two fields.
const passwordChangeSchema = z .object({ password: z.string().min(8), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { error: "Passwords don't match", path: ['confirm'], });The two fields of the object. A cross-field rule can’t live on either one: password’s schema can’t see confirm, and confirm’s can’t see password. The rule has to attach where both fields are visible, which is the object as a whole.
const passwordChangeSchema = z .object({ password: z.string().min(8), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { error: "Passwords don't match", path: ['confirm'], });The predicate. Now data is the whole parsed object, so data.password === data.confirm can finally compare the two. Same convention as the single-field case: true means the pair is acceptable, which here means they match.
const passwordChangeSchema = z .object({ password: z.string().min(8), confirm: z.string(), }) .refine((data) => data.password === data.confirm, { error: "Passwords don't match", path: ['confirm'], });This is the line that makes or breaks the form. path: ['confirm'] tells the validation layer which field this issue belongs to, so the form renders the “don’t match” message under the confirm input. It’s an array because it can point into nested shapes too: path: ['address', 'zip'] would anchor an issue deep inside a nested object.
That third step matters, because skipping it fails in a subtle and damaging way. Leave out path entirely and the refinement still works: the parse still fails when the passwords differ. But the issue attaches to the object’s root instead of to a field. When the form layer goes to render the error, it has nowhere to put it. There’s no input named “the whole object,” so the user gets a vague form-level complaint with no indication of which box is wrong. They see “passwords don’t match” floating above two fields that both look fine to them. That’s not a styling nitpick; it’s a form the user can’t fix.
You don’t need the rest of the wiring today. Exactly how the form reads that path and renders the message under the input is a later story: the error-tree shape is the next lesson, and the form plumbing comes in a later chapter. For now your job is just to author the path correctly, so that machinery has something to anchor to.
Time to write the rule yourself. Below is the schema as a starting point: two fields, but nothing linking them, so a mismatched pair wrongly passes. Add the .refine that requires them to match, with the path. As you do, keep one eye on the ^? query under the schema.
The starter has `password` (min 8) and `confirm`, but no rule linking them, so a mismatched pair wrongly passes. Add a `.refine` that requires `password === confirm`, with `path: ['confirm']`. Watch the `^?` query as you do: it does **not** move. A refinement tightens the runtime contract without touching the inferred type, and that's the line between a refine and a transform.
| Test scenario | Value | |
|---|---|---|
| matching pair | {"password":"longenough","confirm":"longenough"} | |
| mismatched pair | {"password":"longenough","confirm":"different"} | |
| too short | {"password":"short","confirm":"short"} | |
Notice what that exercise just showed you. You made the runtime stricter, so pairs that used to pass now fail, and the inferred type didn’t move at all. That non-movement is the clearest proof of what a refinement is: a tightening of the runtime contract that leaves the type alone. Hold that image, because in two sections you’ll add a transform and watch the ^? query do the exact opposite.
.superRefine when one rule raises many issues
Section titled “.superRefine when one rule raises many issues”.refine has a ceiling, and naming it tells you when to reach past it. A refinement adds one issue when its predicate fails: one predicate, one message. That’s the right shape for most rules. But some rules aren’t a single yes-or-no; they’re a policy with several independent ways to fail, and you want to report all of those failures at once.
The password policy is the canonical case. A real one checks length, an uppercase letter, and a digit. Write that as three separate .refines and the parse stops at the first failure: the user fixes the length, resubmits, now learns they need an uppercase letter, fixes that, resubmits, now learns about the digit. That’s three round-trips for one form, when what you want is to tell them all three at once. That’s the job .superRefine does: one rule that raises multiple distinct issues, each with its own message.
const passwordPolicySchema = z.string().superRefine((value, ctx) => { if (value.length < 8) { ctx.addIssue({ code: 'custom', message: 'At least 8 characters' }); } if (!/[A-Z]/.test(value)) { ctx.addIssue({ code: 'custom', message: 'At least one uppercase letter' }); } if (!/[0-9]/.test(value)) { ctx.addIssue({ code: 'custom', message: 'At least one digit' }); }});The shape is different from .refine, and that difference is the point. Your function gets (value, ctx), the value and a context object, and it does not return a boolean. Instead of returning pass or fail, you push issues onto ctx with ctx.addIssue(...), one call per problem you find. No issues pushed means the value passed. So this function runs all three checks every time, each failing check adds its own issue, and the user gets the full list in a single submission. Each ctx.addIssue can also carry its own path, which is the other thing .refine can’t do: conditional, per-issue paths from a single rule.
One wrinkle to flag so it doesn’t trip you: inside ctx.addIssue({ ... }) the message key is message, not error. That’s a genuine inconsistency with .refine, which uses error in its options object. The reason is that addIssue takes a raw issue object ({ code: 'custom', message } and a few optional fields) rather than the friendlier options bag .refine accepts. Don’t “fix” it to error:; in this spot, message is correct. The full anatomy of an issue, every code and the optional fields, is the next lesson; here, just know message is the key.
.transform reshapes the value and the type
Section titled “.transform reshapes the value and the type”Here’s the crossover. Every tool so far judged the value and handed it back unchanged. A transform changes it. Bring the routing question back one more time: a refinement answered “is this value acceptable?” A transform answers a different question entirely: “what should this value become?”
The clearest case from the running examples: you validated startAt as an ISO datetime string, but the Server Action that consumes it wants to do date math, comparing it or adding days to it. A string is the wrong shape for that; you want a real Date. So after validating the string format, you transform it into a Date.
Here the value changes but the type doesn’t, because uppercasing a string still gives back a string:
z.string().transform((value) => value.toUpperCase());// → output: string (the value changed)This time the type moves with the value: a Date comes out where a string went in:
z.iso.datetime().transform((value) => new Date(value));// → output: Date (the TYPE changed)Two things to take from those two lines.
.transform(fn) returns a new schema whose output is whatever fn returns. The parse no longer hands you back the value you put in; it hands back the transformed one. Uppercase a string and you get the uppercased string out; build a Date from a string and you get a Date out.
And here’s the line that’s been waiting since the framing section: the inferred output type updates to match. z.iso.datetime().transform((s) => new Date(s)) accepts a string but infers as Date. That is the exact opposite of a refinement, and it’s the payoff of watching the ^? query stay put in the earlier exercise. A refinement left the type alone; a transform moves it. Same ^? query, opposite behavior: that contrast is the difference between the two tools, and now you’ve seen both halves of it.
This is when an experienced engineer reaches for a transform: when a field’s job is part validation, part normalization into a domain type. Pairing z.iso.datetime() with .transform((s) => new Date(s)) to land a real Date in a Server Action’s typed input is the canonical example: validate the wire format, then hand the rest of the code the type it actually wants to work with.
One subtlety to name and set aside: once a transform splits “what the parse accepts” (a string) from “what it returns” (a Date), there are now two types in play, not one. The next lesson names the helpers for each: z.input for what goes in, z.output for what comes out. For now, just notice the split exists.
Prove it to yourself. This is the same exercise shape as the matching-passwords one, but watch the ^? query do the opposite of what it did there.
The starter validates `startAt` as an ISO datetime string, so the `^?` query reads `string`. Add `.transform((s) => new Date(s))` and watch it flip to `Date`. The valid string still has to clear the format check *first*: the transform only runs on what already passed. A refinement left the type alone; a transform moves it. That's the whole difference.
| Test scenario | Value | |
|---|---|---|
| valid datetime | "2026-09-01T10:00:00Z" | |
| not a date | "not-a-date" | |
.overwrite for normalization that keeps the type
Section titled “.overwrite for normalization that keeps the type”.transform is the right tool when the type should change, like string to Date. But the most common transform in real code isn’t a type change at all; it’s normalization. Trim the whitespace, lowercase the email, normalize the unicode. Every one of those produces the same type it consumed: a lowercased string is still a string. You don’t want a new type; you want the same type with a cleaned-up value.
You could use .transform for that, and it would run. But it quietly costs you something: .transform widens the inferred type away from the string schema into a generic transform output. The value is still a string, but the schema is no longer a ZodString, which means the string-specific methods are gone and the clean type is muddied. For a job that never meant to change the type, that’s pure downside. .overwrite is Zod 4’s answer: it runs a value-changing function but preserves the input type. Put the two side by side and the cost is visible.
z.string().transform((v) => v.trim().toLowerCase());Normalizes correctly, but the schema is no longer a ZodString; the type generalized into a transform output. The value that comes out is still a string, yet the schema’s type changed, so downstream code loses the string-schema methods and the clean ZodString type.
z.string().overwrite((v) => v.trim().toLowerCase());Same normalization, still a ZodString; downstream code keeps every string method and the clean type. This is the default reach for normalization in v4.
You’ve seen this idea already. The last lesson introduced .trim(), .toLowerCase(), and .toUpperCase() as built-in normalizers; they change the value but keep the type string. .overwrite is the general-purpose version of that behavior, for normalization the built-ins don’t cover: NFC unicode normalization, stripping a currency symbol off an amount, collapsing internal whitespace. Think of it as .trim()’s machinery, opened up for your own normalizers.
That gives you the cleanest decision rule in the lesson, and it folds the watch-out right in:
.pipe for validation after a transform
Section titled “.pipe for validation after a transform”This is the heaviest tool, and the last, because you’ll reach for it least. Here’s the trigger: a .transform produces a value, that value sometimes needs its own validation, and you’d rather express that validation as a real schema than hand-roll a check inside the transform. .pipe chains two schemas end to end: the first schema’s output becomes the second schema’s input.
z.string() .transform((value) => Number(value)) .pipe(z.number().int().positive());Read that as a sequence: a string comes in and passes z.string(), the transform turns it into a number, and then .pipe(z.number().int().positive()) runs a second, full validation pass on that number, checking that it’s an integer and positive. That second pass is the thing a bare transform can’t do. A transform reshapes and hands back; it doesn’t re-validate its own output, but .pipe does. Watch the stages move:
The piped schema validates the transformed value. 42 is a positive integer, so it passes, but "-1" would clear step 1 and then fail right here.
That’s .pipe: use it when the post-transform validation is itself a real schema, like a constrained number or another object shape, not a one-liner you could fold into the transform.
Be honest about its weight, though, because the everyday version of this job has a lighter, dedicated tool. The most common “string from a form, needs to be a number” case isn’t a .pipe at all; it’s z.coerce.number(), which you’ll meet a couple of lessons from now. .pipe earns its place only when coercion’s defaults don’t fit your case and the follow-on validation is a genuine schema. Reach for it knowing it’s the heavy option, not the default one.
The transform that runs even after a refine fails
Section titled “The transform that runs even after a refine fails”There’s one v4 behavior that will surprise you if your intuition came from older Zod, and it’s worth its own section because it can cause real production bugs, not just a footnote. The surprise:
A .transform in a schema chain can run even when an earlier .refine on that chain has already failed.
If you learned Zod on v3, or from an AI trained on it, you expect a failed refinement to short-circuit everything downstream: a check fails, the parse is over, nothing after it runs. That was the old behavior. It is not the v4 behavior. v4 made a deliberate change for performance, so a transform later in the chain can execute even after a refine before it has already raised its issue. Don’t take this on faith; watch the real runtime do it.
So what do you do with that? The takeaway isn’t “be afraid of chaining”; it’s a small discipline that makes the surprise a non-issue:
A transform that would throw on the very input an earlier refine rejects is a latent bug. Keep the transform robust on its own, keep the refine as the thing that reports the problem, and the order between them stops mattering.
Pure checks in the schema, side-effects at the action
Section titled “Pure checks in the schema, side-effects at the action”One boundary to close on, and it’s not new. The last lesson drew a line for format rules: shape and format belong in the schema, but cross-resource questions like “is this email already registered” belong at the action layer, because answering them needs a database the schema has no business touching. Everything you learned today falls on the same line, so it’s worth drawing once more, now for custom logic.
On the schema goes any rule the schema can prove from the value or values alone, with no outside help. That’s the entire lesson: a single-field .refine, a cross-field .refine, a .superRefine policy, a transform, a normalization. “Do these two passwords match?” needs nothing but the two passwords. “Is dueAt after issuedAt?” needs nothing but the two dates. “Is the email lowercased?” needs nothing but the email. The schema has everything it needs in hand.
In the Server Action body, after the parse goes any rule that needs a database lookup or an external call. “Is this email already registered?” “Is this org slug taken?” “Does the customer’s current plan allow another invoice?” None of those can be a predicate on a schema, because the value alone can’t answer them; you have to go ask something outside the value.
The failure mode is the same one the last lesson named: a schema that needs a database connection to parse has crossed a line. It can’t run in a test without spinning up a database, it can’t run at the edge, and it tangles pure validation with live infrastructure. The fix is the boundary: pure checks in the schema, side-effects in the action, where each side-effect gets its own typed error path the form renders. (The action layer is the very next chapter; the form rendering comes shortly after.)
Make the judgment yourself on a set of real rules. For each one, ask the question that decides it: can the schema prove this from the value alone, or does it need to go ask the database?
Each rule needs a home. Sort it: can the schema prove it from the value alone, or does it need the database? Drag each item into the bucket it belongs to, then press Check.
dueAt is after issuedAtThat sort captures the whole lesson. Everything you can prove from the value (refinements, transforms, normalization) stays in the schema, where it’s pure and portable. Everything that needs to ask the world a question waits for the action. It’s one line, and you’ll draw it on every rule you ever write.
Where to go deeper
Section titled “Where to go deeper”The Zod documentation’s schema-definition reference is the full catalog for refinements and transforms: every option .refine takes, the ctx surface of .superRefine, and the .transform, .overwrite, and .pipe family in Zod’s own words. The Zod Playground is the fastest way to feel the behaviors from this lesson: build a refine-plus-transform chain, throw inputs at it, and watch the order of operations live, including the v4 transform-after-refine surprise you met above.
The refinements and transforms reference: .refine, .superRefine, .transform, .overwrite, and .pipe, with the options each takes.
Build refine and transform chains and run real inputs through them live, including the order-of-operations behaviors from this lesson.
The v4 changes behind this lesson, straight from the source: refinements as a checks array, the new .overwrite, and the unified error parameter.
The exact v4 trap from this lesson, as a real bug report: a .transform that executes even when an earlier .refine has failed.