Zod Transforms
Shape, coerce, and add custom validation logic with transform, refine, superRefine, preprocess, and pipe.
Recipe
Quick-reference recipe card — copy-paste ready.
import { z } from "zod";
// transform — change the output value
const Trimmed = z.string().transform((s) => s.trim());
const Lower = z.string().transform((s) => s.toLowerCase());
// refine — custom validation predicate
const EvenNumber = z.number().refine((n) => n % 2 === 0, {
message: "Must be an even number",
});
// superRefine — multiple issues, full control
const PasswordSchema = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least 8 characters" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least one uppercase letter" });
}
if (!/\d/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least one digit" });
}
});
// preprocess — coerce before validation
const CoercedNumber = z.preprocess((val) => Number(val), z.number().positive());
// pipe — chain schemas together
const StringToNumber = z.string().pipe(z.coerce.number().int().positive());When to reach for this: When basic type validation is not enough — you need to normalize data, apply business rules, or chain transformations.
Working Example
"use client";
import { useState } from "react";
import { z } from "zod";
const SignupSchema = z
.object({
username: z
.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores")
.transform((s) => s.toLowerCase()),
email: z.string().email().transform((s) => s.toLowerCase().trim()),
password: z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least 8 characters" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Needs an uppercase letter" });
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Needs a digit" });
}
}),
confirmPassword: z.string(),
age: z.preprocess((val) => Number(val), z.number().int().min(13, "Must be 13+")),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type SignupInput = z.input<typeof SignupSchema>;
type SignupOutput = z.output<typeof SignupSchema>;
export function SignupForm() {
const [output, setOutput] = useState<string>("");
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const raw = Object.fromEntries(fd);
const result = SignupSchema.safeParse(raw);
if (result.success) {
setOutput("Valid: " + JSON.stringify(result.data, null, 2));
} else {
setOutput(result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"));
}
}
return (
<form onSubmit={handleSubmit} className="flex max-w-md flex-col gap-3">
<input name="username" placeholder="Username" className="rounded border p-2" />
<input name="email" placeholder="Email" className="rounded border p-2" />
<input name="password" type="password" placeholder="Password" className="rounded border p-2" />
<input name="confirmPassword" type="password" placeholder="Confirm" className="rounded border p-2" />
<input name="age" placeholder="Age" type="number" className="rounded border p-2" />
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">Sign Up</button>
{output && <pre className="rounded bg-gray-50 p-3 text-sm whitespace-pre-wrap">{output}</pre>}
</form>
);
}What this demonstrates:
transformto normalize username and emailsuperRefinefor multi-rule password validationpreprocessto coerce string age to number- Object-level
.refinefor cross-field validation (password match)
Deep Dive
How It Works
- transform runs after validation passes and maps the output to a new value (can change the type)
- refine adds a predicate check; returning
falsecreates a single issue - superRefine gives full access to the
RefinementCtxso you can add multiple issues - preprocess runs a function on the raw input before any schema validation
- pipe passes the output of one schema as input to another — useful for multi-step transformations
- Transforms change the output type, which means
z.inputandz.outputmay differ
Variations
Async refine for server-side checks:
const UniqueEmail = z.string().email().refine(
async (email) => {
const exists = await db.user.findUnique({ where: { email } });
return !exists;
},
{ message: "Email already registered" }
);
// Must use parseAsync / safeParseAsync
const result = await UniqueEmail.safeParseAsync("test@example.com");Chaining transforms:
const CsvToNumbers = z
.string()
.transform((s) => s.split(","))
.transform((arr) => arr.map(Number))
.pipe(z.array(z.number().finite()));Branded types with refine:
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>; // string & { __brand: "UserId" }
function getUser(id: UserId) { /* ... */ }
// getUser("random-string") — compile error
// getUser(UserId.parse("550e...")) — worksDefault + transform pipeline:
const Config = z.object({
port: z.coerce.number().default(3000),
host: z.string().default("localhost").transform((h) => h.toLowerCase()),
debug: z.preprocess((v) => v === "true" || v === true, z.boolean()).default(false),
});TypeScript Notes
// When a schema has transforms, input and output types differ
const S = z.string().transform((s) => s.length);
type SInput = z.input<typeof S>; // string
type SOutput = z.output<typeof S>; // number
type SInfer = z.infer<typeof S>; // number (same as output)
// superRefine can narrow types
const NonEmpty = z.array(z.string()).superRefine((arr, ctx): arr is [string, ...string[]] => {
if (arr.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Need at least one" });
return false;
}
return true;
});Gotchas
-
refine runs only if base validation passes — If
z.string().email().refine(...)receives a non-email, the refine callback is never called. This is usually desirable but can surprise you if you expect all errors at once. -
transform changes the type — After
.transform(), downstream validators see the transformed type. If you chain.min()after.transform(), it validates the transformed value. Fix: Put validators before transforms. -
preprocess vs coerce —
z.preprocessis a general-purpose hook;z.coerce.*is shorthand forz.preprocess(Constructor, ...). Preferz.coercefor simple type casting. -
Async refinements require
parseAsync— Calling.parse()on a schema with async refinements throws. Fix: Always useparseAsyncorsafeParseAsync. -
Object-level refine path — If you omit
pathin an object.refine(), the error appears informErrorsinstead offieldErrors. Fix: Always specifypath: ["fieldName"]for object-level refinements.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Yup .test() | You use Formik and need custom validators | You want TypeScript-inferred transformed types |
Valibot transform | You need tree-shakeable transforms with minimal bundle | You rely on Zod-specific .brand() or .pipe() |
| Custom validation functions | Logic is trivial (one field, one check) | You have complex multi-field dependencies |
| Class-validator decorators | You prefer decorator-based validation on class models | You work in a functional / component-based codebase |
FAQs
What is the difference between transform, refine, and superRefine?
transformchanges the output value (and possibly its type)refineadds a single custom validation predicate (returns true/false)superRefinegives full access toRefinementCtxso you can add multiple issues
When should you use preprocess vs z.coerce?
z.coerce.* is shorthand for simple type casting (e.g., string to number via Number()). Use z.preprocess when you need custom coercion logic beyond what a native constructor provides.
How does pipe work and when is it useful?
pipe passes the output of one schema as input to another. It is useful for multi-step transformations:
const CsvToNumbers = z.string()
.transform((s) => s.split(","))
.transform((arr) => arr.map(Number))
.pipe(z.array(z.number().finite()));How do you normalize user input like trimming whitespace and lowercasing?
const Email = z.string().email().transform((s) => s.toLowerCase().trim());
const Username = z.string().min(3).transform((s) => s.toLowerCase());Place validators before transforms so they check the raw input.
How do you validate a password with multiple rules using superRefine?
const Password = z.string().superRefine((val, ctx) => {
if (val.length < 8)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "At least 8 chars" });
if (!/[A-Z]/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Needs uppercase" });
if (!/\d/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Needs a digit" });
});How do you do cross-field validation (e.g., password confirmation)?
Use .refine() on the object schema with a path option:
schema.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});How do you perform async validation like checking if an email exists?
const UniqueEmail = z.string().email().refine(
async (email) => !(await db.user.findUnique({ where: { email } })),
{ message: "Email already registered" }
);
// Must use parseAsync or safeParseAsyncGotcha: Does refine run if the base validation fails?
No. If z.string().email().refine(...) receives a non-email, the refine callback is never called. Base validation must pass first. This means you will not see all errors at once if the base type is wrong.
Gotcha: What happens if you call parse() on a schema with async refinements?
It throws an error. You must use parseAsync() or safeParseAsync() for any schema that contains async refinements.
Why should validators go before transforms in a chain?
After .transform(), downstream validators see the transformed value. If you chain .min() after .transform(), it validates the transformed output, not the original input.
What are branded types and how do you create them with Zod?
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// string & { __brand: "UserId" }
// Prevents passing arbitrary strings where a UserId is expectedTypeScript: How do transforms affect z.input vs z.output?
const S = z.string().transform((s) => s.length);
type SInput = z.input<typeof S>; // string
type SOutput = z.output<typeof S>; // numberThe input and output types diverge whenever a transform changes the type.
TypeScript: Can superRefine narrow a type?
Yes. Use a type predicate return:
const NonEmpty = z.array(z.string()).superRefine(
(arr, ctx): arr is [string, ...string[]] => {
if (arr.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Need one" });
return false;
}
return true;
}
);Related
- Zod Basics — parse, safeParse, error handling
- Zod Types — all built-in types
- Zod Infer — z.infer, z.input, z.output
- RHF + Zod — resolver integration