React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zodtransformrefinesuperRefinepreprocesspipe

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:

  • transform to normalize username and email
  • superRefine for multi-rule password validation
  • preprocess to coerce string age to number
  • Object-level .refine for 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 false creates a single issue
  • superRefine gives full access to the RefinementCtx so 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.input and z.output may 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...")) — works

Default + 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 coercez.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.

Alternatives

AlternativeUse WhenDon't Use When
Yup .test()You use Formik and need custom validatorsYou want TypeScript-inferred transformed types
Valibot transformYou need tree-shakeable transforms with minimal bundleYou rely on Zod-specific .brand() or .pipe()
Custom validation functionsLogic is trivial (one field, one check)You have complex multi-field dependencies
Class-validator decoratorsYou prefer decorator-based validation on class modelsYou work in a functional / component-based codebase

FAQs

What is the difference between transform, refine, and superRefine?
  • 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 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 safeParseAsync
Gotcha: 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 expected
TypeScript: 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>; // number

The 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;
  }
);