EmpireUI
Get Pro
← Blog9 min read#zod#validation#typescript

Zod Guide 2026: Schemas, Transforms, Refinements and Error Formatting

Everything you need to know about Zod in 2026 — schemas, transforms, refinements, async validation, and formatting errors your users will actually understand.

TypeScript code on a dark monitor screen showing schema validation

Why Zod Is Still the Answer in 2026

Zod 3 dropped in 2021 and, honestly, nothing has knocked it off its perch since. Valibot is smaller. Yup has history. But Zod hits the sweet spot most teams actually need: TypeScript-first inference, a readable chainable API, and an ecosystem that just *works* — with React Hook Form, tRPC, Next.js server actions, and a dozen ORMs out of the box.

The 2026 landscape has one real challenger in Valibot, which shaves kilobytes through a modular tree-shakeable design. That said, if you're not shipping a zero-dependency npm package where every byte counts, Zod's DX wins every time. You define a schema, get a type inferred from it for free, and parse data in one line. That's the pitch and it holds up.

Quick aside: Zod v3.23 (released mid-2025) shipped improvements to error maps and tightened z.discriminatedUnion performance noticeably. If you're on anything older than v3.22, upgrade before you read the rest of this — some of the APIs below won't exist.

This guide is for developers who have used Zod before but want to go deeper. We'll cover schema composition, transforms, async refinements, discriminated unions, and the error formatting patterns that make validation errors actually useful to end users.

The Schema Fundamentals You Can't Skip

Every Zod schema is an instance of ZodType. When you call .parse(), Zod runs the schema synchronously and either returns the typed result or throws a ZodError. When you call .safeParse(), you get back a discriminated union — { success: true, data: T } or { success: false, error: ZodError }. Use .safeParse() at form boundaries and API routes. Use .parse() only when you're confident the input is valid and you want to throw loud errors in dev.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.coerce.date(), // coerces ISO strings to Date
});

type User = z.infer<typeof UserSchema>;
// ^ { id: string; email: string; age: number; role: "admin" | "editor" | "viewer"; createdAt: Date }

const result = UserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({ errors: result.error.flatten() });
}
const user = result.data; // fully typed

Worth noting: z.coerce.date() is one of those small touches that separates Zod from manual validation. Instead of pre-processing your JSON payload before handing it to Zod, you let the schema itself handle the coercion. Same story with z.coerce.number() for query-string params that arrive as strings.

Object schemas strip unknown keys by default. That's *usually* what you want on the server — you don't want random fields from client payloads leaking into your database. But if you need to pass unknown keys through, .passthrough() does it. If you want to error on unknown keys, .strict() is there. Know the difference before you open a ticket wondering why your extra field silently disappeared.

Transforms: Shaping Data as It Passes Through

Transforms are where Zod goes from a validator to a full data pipeline. A transform takes the validated value and returns something different — a different shape, a computed value, a class instance. The important thing to understand is that .transform() changes the *output* type. After a transform, z.infer reflects the transformed shape, not the raw input.

const SlugSchema = z
  .string()
  .min(1)
  .transform((val) => val.toLowerCase().replace(/\s+/g, '-'));

type Slug = z.infer<typeof SlugSchema>; // string

// A more complex example — parsing a comma-separated tag list
const TagListSchema = z
  .string()
  .transform((val) =>
    val
      .split(',')
      .map((t) => t.trim())
      .filter(Boolean)
  )
  .pipe(z.array(z.string().min(1)).max(10));

TagListSchema.parse('react, typescript,  zod '); // ['react', 'typescript', 'zod']

The .pipe() at the end of that second example is key. After you transform the string into an array, you can *pipe* the result through a new schema to validate the transformed shape. This is the correct pattern — don't try to chain refinements that operate on the post-transform value on the original string schema. Pipe it.

In practice, transforms shine most in three places: coercing API response shapes to match your domain model, normalising user input (trim, lowercase, slugify), and instantiating classes from plain data. For that last use case, z.transform((val) => new Money(val.amount, val.currency)) is a completely valid pattern and the inferred output type will be Money.

One more thing — transforms are always synchronous. If you need async work (database lookups, external API calls), you want refinements with .superRefine() or parseAsync(). More on that next.

Refinements and Async Validation

Refinements let you add validation logic that Zod can't express with its built-in primitives. The simplest form is .refine(fn, message). The function receives the parsed value and returns a boolean — true means valid, false means fail.

const PasswordSchema = z
  .string()
  .min(8, 'At least 8 characters')
  .refine((val) => /[A-Z]/.test(val), 'Must contain an uppercase letter')
  .refine((val) => /[0-9]/.test(val), 'Must contain a number')
  .refine((val) => /[^a-zA-Z0-9]/.test(val), 'Must contain a special character');

// Cross-field validation with superRefine
const ResetPasswordSchema = z
  .object({
    password: PasswordSchema,
    confirmPassword: z.string(),
  })
  .superRefine(({ password, confirmPassword }, ctx) => {
    if (password !== confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Passwords do not match',
        path: ['confirmPassword'], // attach the error to the right field
      });
    }
  });

.superRefine() is the power-user version. It gives you the ctx object so you can add multiple issues, attach errors to specific field paths, and control whether Zod keeps running downstream validations after this check fails (using z.NEVER). Use it for cross-field validation — things like "end date must be after start date" or "if role is admin, org must be set".

Async refinements are where things get interesting. Zod has first-class support for them — you return a Promise from your refinement function, then call schema.parseAsync(data) instead of schema.parse(data). This is the right way to do uniqueness checks (is this email already in the database?) without hacking around Zod's sync model.

const UniqueEmailSchema = z.string().email().superRefine(async (email, ctx) => {
  const exists = await db.user.findUnique({ where: { email } });
  if (exists) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'This email is already registered',
      path: [],
    });
  }
});

// Must use parseAsync
const result = await UniqueEmailSchema.safeParseAsync(formData.email);

Look, async refinements on every keypress would be a terrible UX. Debounce the call or only trigger it on blur. But on submit, validating uniqueness server-side inside your schema is cleaner than littering your API route handler with ad-hoc checks.

Discriminated Unions and Complex Schemas

Regular z.union([SchemaA, SchemaB]) tries each branch in order and takes the first match. It works, but it's slow on complex schemas because Zod runs full parse attempts on every branch. z.discriminatedUnion('type', [...]) is the fix — you tell Zod which field is the discriminant, and it jumps straight to the right branch in O(1).

const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('page_view'),
    url: z.string().url(),
    referrer: z.string().url().optional(),
  }),
  z.object({
    type: z.literal('button_click'),
    elementId: z.string(),
    label: z.string(),
  }),
  z.object({
    type: z.literal('form_submit'),
    formId: z.string(),
    fields: z.record(z.string()),
  }),
]);

type Event = z.infer<typeof EventSchema>;
// A proper discriminated union — TypeScript narrows correctly on .type

This pattern is essential when you're validating webhook payloads, analytics events, or any system where a single endpoint receives multiple event shapes. The TypeScript inference is perfect — inside a switch (event.type) block, you get the narrowed type with only the fields relevant to that branch.

Worth noting: nested discriminated unions work too. You can have a top-level union on category with each branch containing its own discriminated union on action. It gets verbose quickly, but for complex domain models it beats maintaining parallel type declarations and manual validation logic.

Error Formatting That Your Users Will Actually Read

The default ZodError has an issues array full of detailed info, but you rarely want to dump that raw structure into your UI. Zod ships three formatting helpers: .format(), .flatten(), and .formErrors. Each gives you a different shape — pick based on your use case.

const schema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  address: z.object({
    city: z.string(),
    zip: z.string().regex(/^\d{5}$/),
  }),
});

const result = schema.safeParse({ name: 'A', email: 'bad', address: { city: '', zip: '123' } });

if (!result.success) {
  // .flatten() — best for flat forms
  console.log(result.error.flatten());
  // {
  //   formErrors: [],
  //   fieldErrors: {
  //     name: ['String must contain at least 2 character(s)'],
  //     email: ['Invalid email'],
  //   }
  // }

  // .format() — best for nested shapes, mirrors the schema structure
  console.log(result.error.format());
  // {
  //   _errors: [],
  //   name: { _errors: ['String must contain at least 2 character(s)'] },
  //   email: { _errors: ['Invalid email'] },
  //   address: { _errors: [], city: { _errors: [...] }, zip: { _errors: [...] } }
  // }
}

For React Hook Form users, you'll almost never touch these directly — the zodResolver from @hookform/resolvers/zod handles error mapping to field state automatically. But in server actions and API routes you're formatting errors manually, and .flatten() is usually the right call for a flat { fieldErrors: Record<string, string[]> } response.

Custom error messages deserve more attention than most Zod tutorials give them. Instead of passing a plain string, you can pass an object with a message key and any extra metadata you want to attach. Then you can read that metadata in an errorMap to implement i18n, field-level severity, or error codes that your frontend can act on.

// Custom error map — translate Zod's built-in messages
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small && issue.type === 'string') {
    return { message: `Min ${issue.minimum} characters required` };
  }
  if (issue.code === z.ZodIssueCode.invalid_string && issue.validation === 'email') {
    return { message: 'Enter a valid email address' };
  }
  return { message: ctx.defaultError };
};

z.setGlobalErrorMap(customErrorMap);
// All schemas now use this map — good for a single i18n locale

Honestly, the biggest DX win here isn't the formatting — it's consistent error message strings. Pick your messages once in the schema, not scattered across components. When your designer asks why the email error says three different things depending on which form you're on, the answer is usually "because the error message lives in the component". Don't do that.

Integrating Zod With React Hook Form and Server Actions

If you're not using zodResolver with React Hook Form already, start now. Install @hookform/resolvers and pass zodResolver(YourSchema) as the resolver option. You get type-safe register, watch, and handleSubmit for free, plus automatic error state synced from your Zod schema.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(2, 'Name too short'),
  email: z.string().email('Invalid email'),
  message: z.string().min(20, 'At least 20 characters').max(500),
});

type ContactForm = z.infer<typeof ContactSchema>;

export function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<ContactForm>({
    resolver: zodResolver(ContactSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('name')} />
      {errors.name && <p className="text-red-500 text-sm">{errors.name.message}</p>}
      <input type="email" {...register('email')} />
      {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
      <textarea {...register('message')} />
      {errors.message && <p className="text-red-500 text-sm">{errors.message.message}</p>}
      <button type="submit">Send</button>
    </form>
  );
}

For Next.js server actions (available since Next 13.4, mature since 14), you validate inside the action using .safeParseAsync() and return a structured error object. The pattern pairs naturally with useActionState on the client — your server validates, returns { errors: fieldErrors }, and your component reads from action state.

// app/actions/contact.ts
'use server';
import { z } from 'zod';
import { ContactSchema } from '@/lib/schemas';

export async function submitContact(_prevState: unknown, formData: FormData) {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = ContactSchema.safeParse(raw);
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await sendEmail(result.data);
  return { success: true };
}

One more thing — share your schemas between client and server. Keep them in lib/schemas.ts or a schemas/ directory at the project root. This is the real value: you write the validation logic once, and it runs identically whether the user hits submit without JavaScript (server action) or while React is hydrated (client-side RHF). If you're building UI-heavy forms, Empire UI's component library includes pre-styled form primitives that slot into this exact pattern. Pair them with your Zod schemas and you're shipping fast.

FAQ

What's the difference between .parse() and .safeParse() in Zod?

.parse() throws a ZodError if validation fails — use it where you want loud failures in dev or are confident the data is already valid. .safeParse() returns a discriminated union { success, data } or { success, error } without throwing, which is what you want at API route and form submit boundaries.

Can Zod validate data asynchronously, like checking a database?

Yes. Return a Promise from a .superRefine() or .refine() callback, then call schema.parseAsync() or schema.safeParseAsync() instead of the sync versions. Zod awaits all async refinements before returning. Just don't do it on every keypress — debounce or trigger only on blur and submit.

How do I attach a Zod validation error to a specific nested field?

Use .superRefine() and call ctx.addIssue({ code: z.ZodIssueCode.custom, message: '...', path: ['fieldName'] }). The path array mirrors the schema structure — for nested objects use ['address', 'zip']. This makes the error show up on the correct field when you call .format() or use zodResolver with React Hook Form.

Should I use Zod or Valibot in 2026?

Zod for application code — better DX, richer ecosystem (tRPC, RHF, Prisma, Next.js), and the bundle size difference is negligible in an app. Valibot for npm packages or edge runtimes where you're genuinely tree-shaking aggressively and every kilobyte matters. The comparison is covered in more detail in the zod-vs-yup-vs-valibot article.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Drizzle ORM Guide: Schema, Queries, Migrations and the Drizzle KitPublishing npm Packages: From Local Component to Public LibraryZod vs Yup vs Valibot in 2026: Schema Validation Showdownreact-hook-form + Zod: Full Type-Safe Form Validation in 2026