Zod Showcase

Best Practices

The basics get you started. These patterns cover the decisions you hit once schemas start growing: how to compose and reuse them, how to surface useful error messages, how to validate logic that spans multiple fields.

Schema Composition and Reuse

Build larger schemas from smaller ones using .extend(). This avoids duplication and keeps your schemas maintainable. Try removing createdAt from the input. The base schema requires it.

Schema
import { z } from 'zod';

// Base schema with shared fields
const BaseEntitySchema = z.object({
  id: z.number(),
  createdAt: z.string(),
});

// Extend to add domain-specific fields
const UserSchema = BaseEntitySchema.extend({
  name: z.string(),
  email: z.email(),
});

// .extend() overrides a field if you redeclare it
Inferred Type
type BaseEntity = {
  id: number;
  createdAt: string;
};

type User = {
  id: number;
  createdAt: string;
  name: string;
  email: string;
}
Output
{
  "id": 1,
  "createdAt": "2024-01-15",
  "name": "Alice",
  "email": "alice@example.com"
}

Custom Error Messages

Pass a string or { error } object to any validator to override Zod's default error message. Try submitting with a short username or an invalid email to see the custom messages.

Schema
import { z } from 'zod';

const schema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters'),
  email: z.email('Please enter a valid email address'),
  age: z.number()
    .min(18, { error: 'You must be 18 years or older' }),
});
Inferred Type
type Schema = {
  username: string;
  email: string;
  age: number;
}
Output
  • username Username must be at least 3 characters
  • email Please enter a valid email address
  • age You must be 18 years or older

.parse() vs .safeParse()

Both methods validate data against a schema, but they handle failure differently. .parse() throws a ZodError on failure, use it where bad data is truly unexpected, like parsing config at startup or validating already-trusted internal data. .safeParse() never throws. It returns { success: true, data } or { success: false, error }, which makes it the right choice for user input, API requests, and anywhere failure is a normal outcome you need to handle gracefully. The default input below is intentionally invalid. Try fixing it to see safeParse succeed.

Schema
import { z } from 'zod';

const ProductSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
  category: z.enum(['food', 'clothing', 'electronics']),
});

// .parse() throws on failure. Use for trusted data.
const product = ProductSchema.parse(data); // throws ZodError

// .safeParse() never throws. Use for user input / requests.
const result = ProductSchema.safeParse(data);
if (result.success) {
  console.log(result.data.name);  // fully typed
} else {
  console.log(result.error.issues); // structured error list
}
Inferred Type
// .safeParse() returns a discriminated union:
type Result =
  | { success: true;  data: Product }
  | { success: false; error: ZodError }

type Product = {
  name: string;
  price: number;
  category: 'food' | 'clothing' | 'electronics';
}
Output
  • name Too small: expected string to have >=1 characters
  • price Too small: expected number to be >0
  • category Invalid option: expected one of "food"|"clothing"|"electronics"

Cross-field Validation with .refine()

.refine() adds custom validation logic that runs after the schema passes. It receives the parsed value and returns true (valid) or false (invalid). You can use the path option to attach the error to a specific field rather than the root. Try making the passwords match to see the success state.

Schema
import { z } from 'zod';

const schema = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine(
  data => data.password === data.confirm,
  { error: 'Passwords do not match', path: ['confirm'] }
);

// .refine() can also be chained for multiple rules:
schema.refine(data => !data.password.includes(' '), {
  error: 'Password cannot contain spaces',
  path: ['password'],
});
Inferred Type
type Schema = {
  password: string;
  confirm: string;
}
// .refine() does not change the inferred type.
// It only adds a runtime check on top of the schema.
Output
  • confirm Passwords do not match

Multiple Errors with .superRefine()

.superRefine() gives you full control: call ctx.addIssue() once per problem to surface all errors in a single pass. Unlike .refine(), which stops at the first failure, .superRefine() can report every issue at once. Try using a short, all-lowercase password to see both errors appear together.

Schema
import { z } from 'zod';

const schema = z.object({
  password: z.string(),
}).superRefine(({ password }, ctx) => {
  if (password.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['password'],
      message: 'Must be at least 8 characters',
    });
  }
  if (!/[A-Z]/.test(password)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['password'],
      message: 'Must contain at least one uppercase letter',
    });
  }
});
Inferred Type
// .superRefine() does not change the inferred type.
// It only adds runtime checks on top of the schema.
type Schema = {
  password: string;
}
Output
{
  "password": "Hunter2!"
}

Formatting Errors with z.prettifyError()

z.prettifyError() converts a ZodError into a readable multi-line string, useful for logging, CLI tools, and test output.The default input is intentionally invalid. In the Console panel below you can see the live z.prettifyError() output. Try fixing one field at a time to watch the output shrink.

Schema
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2),
  email: z.email(),
  age: z.number().int().min(18),
});

const result = schema.safeParse(input);
if (!result.success) {
  // Formats all issues as a readable string:
  console.log(z.prettifyError(result.error));
}
Inferred Type
type Schema = {
  name: string;
  email: string;
  age: number;
}
Output
  • name Too small: expected string to have >=2 characters
  • email Invalid email address
  • age Too small: expected number to be >=18
Console
✖ Too small: expected string to have >=2 characters → at name ✖ Invalid email address → at email ✖ Too small: expected number to be >=18 → at age

Debugging Transform Pipelines

When a transform chain produces unexpected output, it can be hard to tell which step is the problem. Because schemas are just code, you can insert a no-op .refine() at any point to inspect the intermediate value. A no-op is a function that does nothing except return true, so the validation always passes and the pipeline continues unchanged. Open the Console panel below to see both intermediate values logged as the schema runs. PS: Remember to remove the debug refines before you commit.

Schema
import { z } from 'zod';

const schema = z.object({
  query: z.string()
    .refine(s => { console.log('[debug] before trim:', s); return true; })
    .transform(s => s.trim())
    .refine(s => { console.log('[debug] after trim:', s); return true; })
    .transform(s => s.toLowerCase())
    .refine(s => { console.log('[debug] after lowercase:', s); return true; }),

  tags: z.string()
    .transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
    .refine(ts => { console.log('[debug] parsed tags:', ts); return true; }),
});
Inferred Type
type Search = {
  query: string;   // trimmed and lowercased
  tags: string[];  // split, trimmed, empty strings removed
}
Output
{
  "query": "hello world",
  "tags": [
    "zod",
    "typescript",
    "svelte"
  ]
}
Console
[debug] before trim: Hello World
[debug] after trim: Hello World
[debug] after lowercase: hello world
[debug] parsed tags: [ "zod", "typescript", "svelte" ]