Zod Part 2: Writing Smarter Schemas
In Part 1, you learned how to describe data with a schema, validate it with safeParse, and read errors when something breaks. That gets you surprisingly far.
But real data has rules that go beyond "this field must be a string" or "this number must be at least 18." A password isn't valid just because it's a string — it needs a number, an uppercase letter, maybe a special character. An address isn't just an object — it's a nested object inside your user object. And your payload to an API is rarely the exact same shape as your form.
This is where Zod's deeper tools come in.
📝 Note: This post was written with the assistance of AI. The content reflects my personal learning and understanding and should not be taken as professional advice. Please do your own research and due diligence before acting on anything written here.
🛠️ Custom Rules — When Built-ins Aren't Enough
Zod's built-in checks like .email() and .min() cover the common cases. But the moment you need a rule that's specific to your app — Zod can't know about it in advance. That's where custom rules come in.
The mental model: Zod's built-in rules are the standard club rules — "must be 18+, must have ID." But your club has an extra rule: "must be on the guest list." You need a way to add that custom check on top of everything else.
Zod gives you two tools for this: .refine() for simple custom rules, and superRefine when you need more control.
✏️ .refine() — One Custom Rule
.refine() takes a function that returns true (passes) or false (fails), plus a custom error message:
import { z } from "zod";
const passwordSchema = z.string().refine(
(val) => /\d/.test(val), // must contain at least one digit
{ message: "Password must contain at least one number" }
);
const result = passwordSchema.safeParse("helloworld"); // ❌ no digit
if (!result.success) {
console.log(result.error.errors[0].message);
// → "Password must contain at least one number"
}
.refine() also works on whole objects — useful when your rule needs to look at two fields together:
const signupSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword, // compare both fields
{
message: "Passwords don't match",
path: ["confirmPassword"], // pin the error to this specific field
}
);
The path option tells Zod which field to attach the error to — so your form knows exactly which input box to highlight in red.
⚡ superRefine — Multiple Custom Rules at Once
.refine() has one limitation: it can only throw one error per call. If your password needs to pass three separate checks, .refine() will only tell the user about the first one that fails.
superRefine fixes this. Instead of returning true or false, it gives you a ctx object — short for context — that lets you add as many errors as you need:
const passwordSchema = z.string().superRefine((val, ctx) => {
// Check 1: must contain a number
if (!/\d/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain at least one number",
});
}
// Check 2: must contain an uppercase letter
if (!/[A-Z]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain at least one uppercase letter",
});
}
// Check 3: must contain a special character
if (!/[!@#$%]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain a special character (!@#$%)",
});
}
});
const result = passwordSchema.safeParse("hello");
if (!result.success) {
result.error.errors.forEach((e) => console.log(e.message));
}
// → "Must contain at least one number"
// → "Must contain at least one uppercase letter"
// → "Must contain a special character (!@#$%)"
All three errors come back at once — exactly what you'd want on a password field, so the user can fix everything in one go.
.refine() | superRefine | |
|---|---|---|
| Errors you can throw | One | As many as you need |
| Best for | Single custom rule | Multiple conditions, fine-grained control |
| Common use case | Passwords match, value in a list | Password strength, complex business rules |
🪆 Nested Schemas — Objects Inside Objects
Real data is rarely flat. A user has an address. An address has a street, a city, and a pincode. That's an object inside an object — and Zod handles it naturally by nesting z.object() inside z.object():
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: z.object({ // nested object — just another z.object()
street: z.string(),
city: z.string(),
pincode: z.string().length(6), // must be exactly 6 characters
}),
});
const result = userSchema.safeParse({
name: "Priya",
email: "priya@example.com",
address: {
street: "12 MG Road",
city: "Hyderabad",
pincode: "500001",
},
});
If something inside address is wrong, Zod's error drills all the way down — the path will be ["address", "pincode"], not just ["pincode"]. You always know exactly where the problem is.
The cleaner pattern — extract and reuse
If a nested schema appears in more than one place (like an address on both a user and an order), define it separately and plug it in wherever you need it:
// Define once
const addressSchema = z.object({
street: z.string(),
city: z.string(),
pincode: z.string().length(6),
});
// Reuse anywhere
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema, // plugged in here
});
const orderSchema = z.object({
orderId: z.string(),
deliveryAddress: addressSchema, // and reused here too
});
One definition, used in multiple places. If the address rules ever change, you update them in one spot.
The mental model: Zod mirrors the shape of your data exactly. However deep your object goes, your schema goes just as deep — and each level is just another
z.object().
✂️ .omit(), .pick(), and .extend() — Composing Schemas
Your form schema and your API payload are often almost the same shape — but not quite. Maybe the form has a confirmPassword field the API doesn't need. Maybe the payload needs an extra field the user never touches.
Rewriting the whole schema from scratch creates duplication. Zod gives you three utilities to derive one schema from another instead:
const signupFormSchema = z.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
});
// .omit() — drop fields you don't want
const signupPayloadSchema = signupFormSchema.omit({ confirmPassword: true });
// → { name, email, password }
// .pick() — keep only the fields you want
const loginSchema = signupFormSchema.pick({ email: true, password: true });
// → { email, password }
// .extend() — add new fields on top of an existing schema
const signupWithRoleSchema = signupFormSchema.extend({
role: z.string(), // new field added
});
// → { name, email, password, confirmPassword, role }
| Utility | What it does |
|---|---|
.omit() | Removes specific fields from a schema |
.pick() | Keeps only specific fields from a schema |
.extend() | Adds new fields to an existing schema |
All three return a brand new schema — your original is never modified.
🎯 What You Can Do Now
- Add custom validation rules with
.refine()for single checks - Use
superRefinewhen one field needs to pass multiple checks simultaneously - Model nested data by composing
z.object()schemas inside each other - Extract reusable nested schemas and plug them in wherever needed
- Derive payload schemas from form schemas using
.omit(),.pick(), and.extend()— without duplicating your rules
Once you start composing schemas from smaller pieces, you stop thinking of validation as something you bolt on at the end — and start designing your data shapes upfront, the same way you'd design anything else in your app.
⏭️ Next Up
In Part 3, we bring everything together — z.infer for TypeScript types, enums for values your backend expects, and the full end-to-end flow of a real signup form: collect → validate → build payload → send to API → validate the response → use the data.

