Zod Showcase

Best Practices

Patterns for writing Zod code that stays readable and maintainable as your project grows.

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
// Note: .merge() was deprecated in Zod v4 — use .extend() instead
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. In Zod v4, use { error: '...' } rather than { message: '...' }, which is deprecated. 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'),
  // z.email() is the v4 top-level format validator
  email: z.email('Please enter a valid email address'),
  // { error } is the v4 preferred form ({ message } is deprecated)
  age: z.number()
    .min(18, { error: 'You must be 18 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 or older

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). 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

Formatting Errors with z.prettifyError()

z.prettifyError() converts a ZodError into a readable multi-line string, useful for logging, CLI tools, and test output. It replaces the deprecated .format() and .flatten() methods from Zod v3. The Output panel shows the raw ZodError issues. In real code you would call z.prettifyError(result.error) to get the formatted string shown in the Inferred Type panel.

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) {
  // Pretty-prints all issues as a readable string:
  console.error(z.prettifyError(result.error));
}
Inferred Type
// Example output of z.prettifyError():
// ✖ name: Too small: expected string to have >=2 characters
//   → at name
// ✖ email: Invalid email address
//   → at email
// ✖ age: Too small: expected number to be >=18
//   → at age
Output
  • name Too small: expected string to have >=2 characters
  • email Invalid email address
  • age Too small: expected number to be >=18

Separating Schemas from Types

Define your schema once and derive the TypeScript type from it with z.infer. Never write a separate interface. If you do, the two will silently drift apart whenever you update the schema.

Schema
import { z } from 'zod';

// ✅ Define the schema — this is the single source of truth
export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
  role: z.enum(['admin', 'user', 'guest']),
});

// ✅ Derive the type — always in sync with the schema
export type User = z.infer<typeof UserSchema>;

// ❌ Don't do this — it can silently diverge
// interface User { id: number; name: string; ... }
Inferred Type
// z.infer derives this type automatically:
type User = {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}
Output
{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin"
}