Zod Transforms
Shape, coerce, and add custom validation logic with transform, refine, superRefine, preprocess, and pipe.
Search across all documentation pages
Shape, coerce, and add custom validation logic with transform, refine, superRefine, preprocess, and pipe.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
transform to normalize username and emailsuperRefine for multi-rule password validationpreprocess to coerce string age to number.refine for cross-field validation (password match)false creates a single issueRefinementCtx so you can add multiple issuesz.input and z.output may differAsync 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),
});// 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;
});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.preprocess is a general-purpose hook; z.coerce.* is shorthand for z.preprocess(Constructor, ...). Prefer z.coerce for simple type casting.
Async refinements require parseAsync - Calling .parse() on a schema with async refinements throws. Fix: Always use parseAsync or safeParseAsync.
Object-level refine path - If you omit path in an object .refine(), the error appears in formErrors instead of fieldErrors. Fix: Always specify path: ["fieldName"] for object-level refinements.
| 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 |
transform changes the output value (and possibly its type)refine adds a single custom validation predicate (returns true/false)superRefine gives full access to RefinementCtx so you can add multiple issuesz.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.
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()));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.
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" });
});Use .refine() on the object schema with a path option:
schema.refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});const UniqueEmail = z.string().email().refine(
async (email) => !(await db.user.findUnique({ where: { email } })),
{ message: "Email already registered" }
);
// Must use parseAsync or safeParseAsyncNo. 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.
It throws an error. You must use parseAsync() or safeParseAsync() for any schema that contains async refinements.
After .transform(), downstream validators see the transformed value. If you chain .min() after .transform(), it validates the transformed output, not the original input.
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// string & { __brand: "UserId" }
// Prevents passing arbitrary strings where a UserId is expectedconst 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.
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;
}
);Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥