🛡️ Lesson 7.3: Form Validation with Zod
In the previous lesson, you learned React Hook Form and saw how it simplifies form handling. But you still wrote validation rules inline with each field—required here, pattern there, custom validators everywhere. What if you could define your entire form's validation schema in one place, get automatic TypeScript types, and write validation that's reusable, composable, and incredibly powerful? That's exactly what Zod gives you. In this lesson, you'll learn how to combine React Hook Form with Zod to create bulletproof, type-safe forms with schema-based validation.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand what Zod is and why schema validation is powerful
- Install and configure Zod with React Hook Form
- Define validation schemas with Zod
- Integrate Zod schemas with React Hook Form using resolvers
- Create reusable validation schemas
- Use advanced Zod features (transforms, refinements, custom errors)
- Get automatic TypeScript type inference from schemas
- Build complex nested schemas for real-world forms
Estimated Time: 60-75 minutes
Project: Build a complete registration form with Zod validation
In This Lesson
🤔 Why Zod?
In Lesson 7.2, you learned React Hook Form and wrote validation like this:
// React Hook Form validation (from Lesson 7.2)
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email format'
},
minLength: {
value: 5,
message: 'Email must be at least 5 characters'
}
})}
/>
<input
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'At least 8 characters' },
validate: {
hasUpper: v => /[A-Z]/.test(v) || 'Need uppercase',
hasLower: v => /[a-z]/.test(v) || 'Need lowercase',
hasNumber: v => /\d/.test(v) || 'Need number'
}
})}
/>
❌ Problems with Inline Validation
- Repetitive - Copy-paste validation across forms
- Hard to maintain - Update validation in multiple places
- No reusability - Can't share validation logic
- Manual types - Define TypeScript types separately
- Scattered logic - Validation rules mixed with UI code
- Limited composition - Hard to combine validations
- Runtime-only - No validation at build time
Enter Zod: Schema-Based Validation
Now see the same form with Zod:
// Zod schema - define once, use everywhere
import { z } from 'zod'; // Zod 4
const formSchema = z.object({
// Zod 4: string formats are top-level helpers (z.email, not z.string().email)
email: z.email('Please enter a valid email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Need uppercase letter')
.regex(/[a-z]/, 'Need lowercase letter')
.regex(/\d/, 'Need number')
});
// TypeScript type automatically inferred!
type FormData = z.infer<typeof formSchema>;
// Result: { email: string; password: string }
// Use with React Hook Form (one line!)
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(formSchema)
});
// Now just register without validation
<input {...register('email')} />
<input {...register('password')} />
✅ Zod Benefits
- Single source of truth - All validation in one schema
- Reusable - Share schemas across forms and API validation
- Composable - Build complex schemas from simple ones
- Type inference - TypeScript types generated automatically
- Runtime + build time - Validation at both stages
- Chainable API - Beautiful, readable validation chains
- Rich validation - 40+ built-in validators
- Small & tree-shakable - Zod 4 slimmed the core, and
zod/minilets bundlers drop unused validators
What is Zod?
Zod is a TypeScript-first schema validation library. Think of it as a way to describe the "shape" of your data—what fields exist, what types they are, and what rules they must follow—all in one place. Here's the mental model that makes Zod click: a schema is a value you build at runtime that doubles as a type at compile time. You write the rules once, and TypeScript reads them right back to you as a fully-typed object. No more keeping a hand-written interface in sync with a separate pile of validation checks—the schema is both.
Why does that matter so much? Because the boundary of your app—form inputs, URL params, API responses, localStorage—is where unknown data sneaks in. TypeScript's types vanish at runtime, so a value the compiler believes is a number can still arrive as the string "25" from an <input>. Zod stands at that boundary like a bouncer checking IDs: nothing gets inside your typed code until it has proven it matches the schema. This lesson uses Zod 4 (the current major version), which is faster, smaller, and moves the common string formats to friendlier top-level helpers.
📖 Key Concepts
- Schema - A definition of what valid data looks like
- Validation - Checking if data matches the schema
- Parsing - Validating and transforming data
- Type inference - Automatic TypeScript types from schemas
- Resolver - Bridge between Zod and React Hook Form
Zod vs Other Validation Libraries
| Feature | Zod 4 | Valibot | Yup | Joi |
|---|---|---|---|---|
| TypeScript | Native, first-class | Native, first-class | Via types package | Limited support |
| Type inference | Automatic ✅ | Automatic ✅ | Manual | Manual |
| Bundle size | Small core; zod/mini tree-shakes to ~2KB |
Tiny (~1–2KB, fully modular) | ~15KB | Large; server-oriented |
| API style | Chainable methods | Composable functions (pipe) | Chainable | Object-based |
| Learning curve | Easy | Easy | Easy | Moderate |
| Ecosystem | Huge (RHF, tRPC, etc.) 🚀 | Growing | Established | Server-focused |
💡 What about Valibot?
Valibot is the up-and-coming alternative worth knowing. Instead of chained methods, it uses standalone functions you compose with pipe()—so bundlers can drop every validator you don't import. That makes it exceptionally small (often 1–2KB), which is a big deal for size-sensitive apps. Zod answered back in v4 with zod/mini, a functional variant with the same tree-shaking benefit. For most React apps the ergonomics and giant ecosystem make Zod the default choice; reach for Valibot or zod/mini when bundle size is your top constraint. Both plug into React Hook Form through @hookform/resolvers.
How Zod Works with React Hook Form
Zod and React Hook Form work together beautifully:
💡 The Power of Schemas
A schema is like a blueprint. Once you define it:
- ✅ Use it for form validation
- ✅ Use it for API request validation
- ✅ Use it for API response validation
- ✅ Share it between frontend and backend
- ✅ Generate documentation from it
- ✅ Get TypeScript types automatically
When to Use Zod
✅ Perfect For:
- Any form with more than basic validation
- Forms where you need strong typing
- Reusable validation across multiple forms
- API request/response validation
- Complex nested data structures
- Projects already using TypeScript
- When you want a single source of truth
❌ Maybe Overkill For:
- Single-field forms (search boxes)
- Prototypes where validation rules are still changing
- Simple forms with only required/minLength validation
- Projects not using TypeScript
📦 Installation and Setup
Let's get Zod installed and integrated with React Hook Form.
Step 1: Install Zod
First, install Zod itself:
# Using npm
npm install zod
# Using yarn
yarn add zod
# Using pnpm
pnpm add zod
Step 2: Install the Resolver
To connect Zod with React Hook Form, you need the resolver package:
# Using npm
npm install @hookform/resolvers
# Using yarn
yarn add @hookform/resolvers
# Using pnpm
pnpm add @hookform/resolvers
💡 What is a Resolver?
A resolver is a bridge that connects validation libraries (like Zod, Valibot, Yup, or Joi) to React Hook Form. Think of it as an adapter plug: React Hook Form speaks one "shape" of validation result, your schema library speaks another, and the resolver translates between them. It tells React Hook Form:
- How to validate form data using your schema
- How to map schema errors onto the right fields
- When to trigger validation
The @hookform/resolvers package ships resolvers for all major libraries. For Zod 4 you want @hookform/resolvers v3.10 or newer—the import path is still @hookform/resolvers/zod.
Step 3: Import and Set Up
Here's the complete setup in a React component:
// 1. Import Zod
import { z } from 'zod';
// 2. Import React Hook Form
import { useForm } from 'react-hook-form';
// 3. Import the Zod resolver
import { zodResolver } from '@hookform/resolvers/zod';
// 4. Define your schema (Zod 4: z.email is a top-level format helper)
const schema = z.object({
email: z.email(),
password: z.string().min(8)
});
// 5. Infer the TypeScript type
type FormData = z.infer<typeof schema>;
// 6. Use in your component
function MyForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
resolver: zodResolver(schema) // Connect Zod to React Hook Form!
});
const onSubmit = (data: FormData) => {
console.log(data); // Fully validated and typed!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
✅ What Just Happened?
- Defined a Zod schema describing valid form data
- Inferred TypeScript type from the schema (no manual typing!)
- Connected schema to React Hook Form via
zodResolver - Registered inputs without inline validation rules
- Zod automatically validates and provides error messages
Minimal Working Example
Here's the absolute minimum to get started:
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// Schema
const schema = z.object({
name: z.string().min(1, 'Name is required')
});
// Type
type FormData = z.infer<typeof schema>;
function SimpleForm() {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('name')} />
<button>Submit</button>
</form>
);
}
⚠️ Common Setup Mistakes
- ❌ Forgetting to install
@hookform/resolvers - ❌ Using
zodresolver instead ofzodResolver - ❌ Not passing the resolver to
useForm</code> - ❌ Defining the schema inside the component (will recreate on every render)
Always define schemas outside your component!
🏋️ Exercise 1: Setup and First Form
Task: Set up Zod with React Hook Form and create a simple login form.
- Install zod and @hookform/resolvers
- Create a LoginForm component
- Define a schema with email and password fields
- Email must be a valid email
- Password must be at least 6 characters
- Show error messages below each field
- Log the data on successful submit
💡 Hint
Import z from 'zod', use z.object() for the schema, z.string() for fields, and chain .email() and .min(). Use zodResolver when calling useForm.
✅ Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// Define schema outside component
const loginSchema = z.object({
email: z.email('Invalid email address'),
password: z.string()
.min(1, 'Password is required')
.min(6, 'Password must be at least 6 characters')
});
// Infer TypeScript type
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema)
});
const onSubmit = (data: LoginFormData) => {
console.log('Login data:', data);
alert(`Logging in with email: ${data.email}`);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{ maxWidth: '400px', margin: '2rem auto' }}
>
<h2>Login</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
Email
</label>
<input
id="email"
type="email"
{...register('email')}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
Password
</label>
<input
id="password"
type="password"
{...register('password')}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.password && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.password.message}
</span>
)}
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem'
}}
>
Login
</button>
</form>
);
}
export default LoginForm;
📝 Your First Zod Schema
Let's build schemas step-by-step to understand how Zod works.
Basic Object Schema
The most common schema is an object with fields:
import { z } from 'zod';
// Define a schema
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string()
});
// The schema is now a validator!
// It can parse and validate data:
// ✅ Valid data
const validUser = {
name: 'Alice',
age: 30,
email: 'alice@example.com'
};
const result = userSchema.parse(validUser);
console.log(result); // { name: 'Alice', age: 30, email: 'alice@example.com' }
// ❌ Invalid data
const invalidUser = {
name: 'Bob',
age: 'thirty', // Wrong type!
email: 'bob@example.com'
};
try {
userSchema.parse(invalidUser);
} catch (error) {
console.error('Validation failed:', error);
// ZodError with detailed error information
}
💡 Key Zod Methods
.parse(data)- Validate and return typed data; throws aZodErrorif invalid.safeParse(data)- Validate and return{ success: true, data }or{ success: false, error }(never throws).parseAsync(data)/.safeParseAsync(data)- Async versions (needed when a schema uses async.refine).partial()- Make all fields optional.required()- Make all fields required.extend({...})- Add more fields to a schema
parse vs safeParse: Throw or Return?
This is the single most important decision when you run a schema, so let's make it concrete. .parse() is the confident option: it hands you clean, typed data or it throws. Great inside a try/catch, or on a trusted server boundary where a failure genuinely is exceptional. .safeParse() is the calm option: it never throws, returning a discriminated result you branch on. That's usually what you want in UI code—an invalid form isn't an "exception", it's Tuesday. React Hook Form's zodResolver uses safeParse under the hood so a validation failure becomes field errors, not a crash.
(fully typed)"] C -->|"false"| E["result.error
(ZodError.issues)"] D --> F["Use safely in typed code"] E --> G["Show messages per field"] style D fill:#48bb78,color:#fff style E fill:#f56565,color:#fff
Building a Schema Step by Step
Let's build a registration form schema progressively:
import { z } from 'zod';
// Start simple
const step1 = z.object({
username: z.string(),
email: z.string()
});
// Add validation rules (Zod 4: z.email() is the top-level format helper)
const step2 = z.object({
username: z.string().min(3).max(20),
email: z.email()
});
// Add custom error messages
const step3 = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters'),
email: z.email('Invalid email address')
});
// Add more fields
const step4 = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters'),
email: z.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
});
// Add field relationships (password confirmation)
const registrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters'),
email: z.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword'] // Error will show on confirmPassword field
});
// Infer the TypeScript type
type RegistrationData = z.infer<typeof registrationSchema>;
// Result: {
// username: string;
// email: string;
// password: string;
// confirmPassword: string;
// }
✅ Schema Building Strategy
- Start with basic structure (field names and types)
- Add simple validation (min, max, email, etc.)
- Add custom error messages
- Add more fields as needed
- Add cross-field validation with
refine() - Test with sample data
Schema Composition
Build complex schemas by composing smaller ones:
import { z } from 'zod';
// Define reusable schemas
const addressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
zipCode: z.string().regex(/^\d{5}$/, 'Zip code must be 5 digits'),
country: z.string().min(1, 'Country is required')
});
const contactSchema = z.object({
phone: z.string().regex(/^\d{10}$/, 'Phone must be 10 digits'),
email: z.email('Invalid email')
});
// Compose them into a larger schema
const userProfileSchema = z.object({
name: z.string().min(1, 'Name is required'),
age: z.number().min(18, 'Must be 18 or older'),
address: addressSchema, // Nested schema!
contact: contactSchema, // Another nested schema!
bio: z.string().optional() // Optional field
});
// Type inference works with nested schemas!
type UserProfile = z.infer<typeof userProfileSchema>;
// Result: {
// name: string;
// age: number;
// address: {
// street: string;
// city: string;
// zipCode: string;
// country: string;
// };
// contact: {
// phone: string;
// email: string;
// };
// bio?: string | undefined;
// }
💡 Benefits of Schema Composition
- Reusability - Use addressSchema across multiple forms
- Maintainability - Update address validation in one place
- Clarity - Each schema has a single responsibility
- Testing - Test schemas independently
- Sharing - Share schemas between frontend and backend
Testing Schemas
Before using a schema in a form, test it with sample data:
import { z } from 'zod';
const userSchema = z.object({
email: z.email(),
age: z.number().min(18)
});
// Test valid data
const validData = { email: 'test@example.com', age: 25 };
const result1 = userSchema.safeParse(validData);
console.log(result1);
// { success: true, data: { email: 'test@example.com', age: 25 } }
// Test invalid data
const invalidData = { email: 'not-an-email', age: 15 };
const result2 = userSchema.safeParse(invalidData);
console.log(result2);
// { success: false, error: ZodError { issues: [...] } }
if (!result2.success) {
// error.issues is the canonical, flat list of what went wrong
console.log('Validation errors:');
result2.error.issues.forEach(issue => {
console.log(`${issue.path.join('.')}: ${issue.message}`);
});
// Zod 4 also gives you higher-level formatters:
console.log(z.prettifyError(result2.error)); // human-readable multi-line string
const tree = z.treeifyError(result2.error); // nested { errors, properties } shape
console.log(tree.properties?.email?.errors); // ['Invalid email address']
}
// Output:
// email: Invalid email address
// age: Too small: expected number to be >=18
💡 Zod 4 error formatting
Reach for the right tool depending on what you're building:
error.issues- the raw array; loop it for custom UI (this is what resolvers read).z.treeifyError(error)- a nested object mirroring your schema's shape; ideal for attaching messages to nested/array fields. (Replaces the olderror.format().)z.flattenError(error)-{ formErrors, fieldErrors }for simple flat forms. (Replaceserror.flatten().)z.prettifyError(error)- a ready-to-print string, perfect for logs and CLI tools.
✅ Best Practice: Test Your Schemas
Always test schemas with both valid and invalid data before using them in production:
- Create a test file for your schemas
- Test edge cases (empty strings, negative numbers, etc.)
- Verify error messages are clear and helpful
- Test nested schemas thoroughly
- Use
safeParsefor testing (doesn't throw errors)
🔗 Integrating with React Hook Form
Now that you understand Zod schemas, let's see how to use them with React Hook Form in practice.
Complete Integration Example
Here's a full working example with all the pieces together:
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. Define the schema
const registrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.email('Invalid email address'),
// Zod 4: one `error` replaces the old required_error / invalid_type_error pair.
// Pass a function if you want different text for "missing" vs "wrong type".
age: z.number({
error: (issue) =>
issue.input === undefined ? 'Age is required' : 'Age must be a number'
})
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
terms: z.boolean()
.refine(val => val === true, {
error: 'You must accept the terms and conditions'
})
}).refine(data => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword']
});
// 2. Infer TypeScript type
type RegistrationFormData = z.infer<typeof registrationSchema>;
// 3. Create the form component
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema)
});
const onSubmit = async (data: RegistrationFormData) => {
console.log('Form data:', data);
// Data is already validated by Zod!
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Registration successful!');
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Create Account</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="username">Username *</label>
<input
id="username"
{...register('username')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.username ? '#f56565' : '#ddd'}`
}}
/>
{errors.username && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.username.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
{...register('email')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="age">Age *</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
}}
/>
{errors.age && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.age.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password">Password *</label>
<input
id="password"
type="password"
{...register('password')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`
}}
/>
{errors.password && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.password.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="confirmPassword">Confirm Password *</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.confirmPassword ? '#f56565' : '#ddd'}`
}}
/>
{errors.confirmPassword && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.confirmPassword.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" {...register('terms')} />
I accept the terms and conditions *
</label>
{errors.terms && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.terms.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.75rem',
background: isSubmitting ? '#ddd' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSubmitting ? 'not-allowed' : 'pointer'
}}
>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
</form>
);
}
export default RegistrationForm;
✅ Key Integration Points
zodResolver(schema)- Connects Zod to React Hook Formz.infer<typeof schema>- Automatic TypeScript types{...register('fieldName')}- No inline validation needed!valueAsNumber: true- Convert number inputs to numberserrors.fieldName.message- Zod's custom error messages- Cross-field validation with
refine()works automatically
Validation Modes with Zod
Control when Zod validates your form:
// Validate on submit only (default)
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onSubmit'
});
// Validate on blur (good UX)
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur'
});
// Validate on change (real-time feedback)
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onChange'
});
// Validate on blur, then on change (best UX)
const { register } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched'
});
💡 Recommended Mode for Zod
Use mode: 'onTouched' for the best user experience with Zod:
- User isn't bothered while typing
- Validation happens after they leave a field
- Subsequent changes show real-time feedback
- Balances helpfulness with intrusiveness
Handling Number and Date Inputs
Special handling for non-string inputs:
const schema = z.object({
age: z.number(),
birthDate: z.date(),
quantity: z.number().int().positive()
});
type FormData = z.infer<typeof schema>;
function NumberDateForm() {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
{/* For number inputs, use valueAsNumber */}
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
{/* For date inputs, use valueAsDate */}
<input
type="date"
{...register('birthDate', { valueAsDate: true })}
/>
{/* Integer input */}
<input
type="number"
{...register('quantity', { valueAsNumber: true })}
/>
<button type="submit">Submit</button>
</form>
);
}
⚠️ Important: Type Coercion
HTML inputs always return strings by default. You must tell React Hook Form to convert them:
valueAsNumber: true- For number inputsvalueAsDate: true- For date inputs
Without these, Zod will receive strings and validation will fail!
🏋️ Exercise 2: Complete Form Integration
Task: Build a profile form with Zod validation.
Requirements:
- Fields: firstName, lastName, email, age, bio
- firstName and lastName: required, 2-50 characters
- Email: required, valid email format
- Age: required, number between 18-120
- Bio: optional, max 200 characters
- Use mode: 'onTouched' for validation
- Show all error messages
- Infer TypeScript types from schema
💡 Hint
Use z.object() with z.string() and z.number(). Chain .min() and .max() for validation. Use .optional() for bio. Don't forget valueAsNumber for age!
✅ Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// Define schema
const profileSchema = z.object({
firstName: z.string()
.min(1, 'First name is required')
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name cannot exceed 50 characters'),
lastName: z.string()
.min(1, 'Last name is required')
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name cannot exceed 50 characters'),
email: z.email('Invalid email address'),
age: z.number({
error: (issue) =>
issue.input === undefined ? 'Age is required' : 'Age must be a number'
})
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age'),
bio: z.string()
.max(200, 'Bio cannot exceed 200 characters')
.optional()
});
// Infer TypeScript type
type ProfileFormData = z.infer<typeof profileSchema>;
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
mode: 'onTouched'
});
const onSubmit = (data: ProfileFormData) => {
console.log('Profile data:', data);
alert('Profile updated successfully!');
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Update Profile</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="firstName">First Name *</label>
<input
id="firstName"
{...register('firstName')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.firstName ? '#f56565' : '#ddd'}`
}}
/>
{errors.firstName && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.firstName.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="lastName">Last Name *</label>
<input
id="lastName"
{...register('lastName')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.lastName ? '#f56565' : '#ddd'}`
}}
/>
{errors.lastName && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.lastName.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
{...register('email')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="age">Age *</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
}}
/>
{errors.age && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.age.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="bio">Bio (Optional)</label>
<textarea
id="bio"
{...register('bio')}
rows={4}
placeholder="Tell us about yourself..."
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
fontFamily: 'inherit',
border: `1px solid ${errors.bio ? '#f56565' : '#ddd'}`
}}
/>
{errors.bio && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.bio.message}
</span>
)}
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Update Profile
</button>
</form>
);
}
export default ProfileForm;
🔤 Basic Zod Types
Zod provides types for all JavaScript primitives and more. Let's explore the fundamental building blocks.
Primitive Types
import { z } from 'zod';
// String
const stringSchema = z.string();
stringSchema.parse('hello'); // ✅ 'hello'
stringSchema.parse(123); // ❌ Error
// Number
const numberSchema = z.number();
numberSchema.parse(42); // ✅ 42
numberSchema.parse('42'); // ❌ Error
// Boolean
const booleanSchema = z.boolean();
booleanSchema.parse(true); // ✅ true
booleanSchema.parse('true'); // ❌ Error
// BigInt
const bigintSchema = z.bigint();
bigintSchema.parse(9007199254740991n); // ✅
// Date
const dateSchema = z.date();
dateSchema.parse(new Date()); // ✅
dateSchema.parse('2024-01-01'); // ❌ Error
// Undefined
const undefinedSchema = z.undefined();
undefinedSchema.parse(undefined); // ✅
// Null
const nullSchema = z.null();
nullSchema.parse(null); // ✅
// Void (accepts undefined)
const voidSchema = z.void();
voidSchema.parse(undefined); // ✅
String Schema
The string type is one of the most commonly used:
const schema = z.object({
// Basic string
name: z.string(),
// Required string (not empty)
username: z.string().min(1, 'Username is required'),
// String with length constraints
password: z.string()
.min(8, 'At least 8 characters')
.max(100, 'Maximum 100 characters'),
// Email validation (Zod 4: top-level helper)
email: z.email('Invalid email'),
// URL validation
website: z.url('Invalid URL'),
// UUID validation
id: z.uuid('Invalid UUID'),
// Custom regex
phone: z.string().regex(/^\d{10}$/, 'Phone must be 10 digits'),
// Specific format
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid zip code'),
// Optional string
bio: z.string().optional(),
// String with default value
role: z.string().default('user'),
// Nullable string
middleName: z.string().nullable()
});
Number Schema
const schema = z.object({
// Basic number
age: z.number(),
// Positive number
price: z.number().positive('Price must be positive'),
// Non-negative (0 or greater)
quantity: z.number().nonnegative('Cannot be negative'),
// Negative number
temperature: z.number().negative(),
// Number with min/max
rating: z.number()
.min(1, 'Minimum rating is 1')
.max(5, 'Maximum rating is 5'),
// Integer only
count: z.number().int('Must be an integer'),
// Multiple of (divisible by)
discount: z.number().multipleOf(5, 'Must be multiple of 5'),
// Safe integer (JavaScript safe range)
bigNumber: z.number().safe(),
// Finite number (not Infinity or NaN)
score: z.number().finite(),
// Optional number
optionalAge: z.number().optional(),
// Number with default
priority: z.number().default(0)
});
Boolean Schema
const schema = z.object({
// Basic boolean
isActive: z.boolean(),
// Boolean that must be true (for terms acceptance)
terms: z.boolean()
.refine(val => val === true, {
error: 'You must accept the terms'
}),
// Optional boolean
newsletter: z.boolean().optional(),
// Boolean with default
emailNotifications: z.boolean().default(true)
});
Array Schema
// Array of strings
const tagsSchema = z.array(z.string());
tagsSchema.parse(['tag1', 'tag2']); // ✅
// Array with constraints
const schema = z.object({
// Array with min/max length
tags: z.array(z.string())
.min(1, 'At least one tag required')
.max(5, 'Maximum 5 tags allowed'),
// Array of numbers
scores: z.array(z.number()),
// Array of objects
friends: z.array(z.object({
name: z.string(),
age: z.number()
})),
// Non-empty array
categories: z.array(z.string()).nonempty('Select at least one category'),
// Optional array
hobbies: z.array(z.string()).optional(),
// Array with default
permissions: z.array(z.string()).default(['read'])
});
Object Schema
// Nested objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string(),
zipCode: z.string()
});
const userSchema = z.object({
name: z.string(),
email: z.email(),
address: addressSchema, // Nested object
// Object with dynamic keys (Zod 4 requires both key and value schemas)
metadata: z.record(z.string(), z.unknown()),
// Optional object
preferences: z.object({
theme: z.string(),
language: z.string()
}).optional()
});
Enum Schema
// String enum
const roleSchema = z.enum(['admin', 'user', 'guest']);
roleSchema.parse('admin'); // ✅
roleSchema.parse('superuser'); // ❌ Error
// In a form
const schema = z.object({
role: z.enum(['admin', 'user', 'guest']),
// With custom error (Zod 4: a plain `error` string replaces the old errorMap)
status: z.enum(['active', 'inactive', 'pending'], {
error: 'Invalid status'
}),
// From a TypeScript enum
priority: z.enum(Priority) // where Priority is a TS enum
});
// TypeScript enum example
enum Priority {
Low = 'low',
Medium = 'medium',
High = 'high'
}
// Zod 4: z.enum() accepts native TS enums directly (z.nativeEnum is now deprecated)
const taskSchema = z.object({
title: z.string(),
priority: z.enum(Priority)
});
Union Types
// Union of primitives
const idSchema = z.union([z.string(), z.number()]);
idSchema.parse('123'); // ✅
idSchema.parse(456); // ✅
idSchema.parse(true); // ❌
// In a form
const schema = z.object({
// Field can be string or number
identifier: z.union([z.string(), z.number()]),
// Contact preference (a discriminated union — see the tip below)
contact: z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), email: z.email() }),
z.object({ type: z.literal('phone'), phone: z.string() })
])
});
✅ Prefer discriminatedUnion for tagged shapes
When each branch of a union carries a shared literal "tag" field (like type: 'email' vs type: 'phone'), use z.discriminatedUnion('type', [...]) instead of a plain z.union. Zod reads the tag first and validates only the matching branch, so it's faster and—crucially—produces a precise error on the right field instead of a confusing "no union member matched" dump. TypeScript infers it as a clean discriminated union too, so switch (contact.type) narrows perfectly.
💡 Type Reference Quick Guide
| Type | Zod Code | TypeScript Type |
|---|---|---|
| String | z.string() |
string |
| Number | z.number() |
number |
| Boolean | z.boolean() |
boolean |
| Array | z.array(z.string()) |
string[] |
| Object | z.object({...}) |
{ ... } |
| Optional | z.string().optional() |
string | undefined |
| Nullable | z.string().nullable() |
string | null |
| Enum | z.enum(['a', 'b']) |
'a' | 'b' |
Optional vs Nullable vs Default
Understanding the difference is crucial:
const schema = z.object({
// Optional - field can be omitted or undefined
bio: z.string().optional(),
// Type: string | undefined
// Valid: { bio: 'text' }, { bio: undefined }, {}
// Nullable - field must exist but can be null
middleName: z.string().nullable(),
// Type: string | null
// Valid: { middleName: 'Jane' }, { middleName: null }
// Invalid: {} (field required)
// Default - field uses default if omitted
role: z.string().default('user'),
// Type: string
// Valid: { role: 'admin' }, {}
// Result: { role: 'admin' }, { role: 'user' }
// Optional + Nullable
nickname: z.string().optional().nullable(),
// Type: string | null | undefined
// Valid: { nickname: 'Bob' }, { nickname: null }, { nickname: undefined }, {}
// Nullable + Default
theme: z.string().nullable().default('dark'),
// Type: string | null
// Valid: { theme: 'light' }, { theme: null }, {}
// Result: { theme: 'light' }, { theme: null }, { theme: 'dark' }
});
✅ When to Use Each
- optional() - Field may or may not be in the data (form fields that can be empty)
- nullable() - Field exists but value might be null (API fields that explicitly use null)
- default() - Provide fallback value when field is missing (configuration options)
📝 String Validation
Zod provides extensive string validation capabilities. Let's explore them in detail.
Length Validation
const schema = z.object({
// Minimum length
username: z.string().min(3, 'Username must be at least 3 characters'),
// Maximum length
bio: z.string().max(200, 'Bio cannot exceed 200 characters'),
// Exact length
zipCode: z.string().length(5, 'Zip code must be exactly 5 digits'),
// Combined min and max
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password cannot exceed 100 characters'),
// Non-empty (at least 1 character)
name: z.string().min(1, 'Name is required')
});
Format Validation
In Zod 4, the common string formats graduated from chained methods (z.string().email()) to standalone top-level helpers (z.email()). The old forms still work but are deprecated—and the top-level versions tree-shake better, since a bundle only pulls in the formats you actually import. Here's the modern lineup:
const schema = z.object({
// Email validation
email: z.email('Invalid email address'),
// URL validation
website: z.url('Invalid URL'),
// UUID validation
id: z.uuid('Invalid UUID'),
// CUID / CUID2 / ULID / nanoid
userId: z.cuid('Invalid CUID'),
sessionId: z.cuid2('Invalid CUID2'),
transactionId: z.ulid('Invalid ULID'),
shortId: z.nanoid('Invalid nanoid'),
// ISO date & time formats now live under z.iso.*
createdAt: z.iso.datetime('Invalid datetime'), // '2026-08-06T12:00:00Z'
birthday: z.iso.date('Invalid date'), // '2026-08-06'
startsAt: z.iso.time('Invalid time'), // '12:00:00'
// IP addresses are split into explicit versions (the old .ip() is gone)
ipv4: z.ipv4('Invalid IPv4 address'),
ipv6: z.ipv6('Invalid IPv6 address'),
// CIDR ranges got dedicated helpers too
subnet: z.cidrv4('Invalid CIDR block')
});
⚠️ Migrating from Zod 3
If you're updating older code, these are the renames you'll hit most:
// Zod 3 (deprecated) → Zod 4 (preferred)
z.string().email() // → z.email()
z.string().url() // → z.url()
z.string().uuid() // → z.uuid()
z.string().datetime() // → z.iso.datetime()
z.string().ip() // → z.ipv4() or z.ipv6()
z.number({ invalid_type_error }) // → z.number({ error })
The deprecated forms still run in Zod 4, so you can migrate gradually—but new code should use the top-level helpers.
Pattern Matching (Regex)
const schema = z.object({
// Phone number (US format)
phone: z.string().regex(
/^\d{3}-\d{3}-\d{4}$/,
'Phone must be in format: 123-456-7890'
),
// Alphanumeric only
username: z.string().regex(
/^[a-zA-Z0-9]+$/,
'Username can only contain letters and numbers'
),
// Starts with capital letter
name: z.string().regex(
/^[A-Z]/,
'Name must start with a capital letter'
),
// Hex color code
color: z.string().regex(
/^#[0-9A-Fa-f]{6}$/,
'Invalid hex color code'
),
// Credit card number (simple check)
creditCard: z.string().regex(
/^\d{4}-\d{4}-\d{4}-\d{4}$/,
'Credit card must be in format: 1234-5678-9012-3456'
),
// Social Security Number
ssn: z.string().regex(
/^\d{3}-\d{2}-\d{4}$/,
'SSN must be in format: 123-45-6789'
)
});
String Transformations
const schema = z.object({
// Trim whitespace
name: z.string().trim(),
// Convert to lowercase
email: z.string().toLowerCase().email(),
// Convert to uppercase
code: z.string().toUpperCase(),
// Chain transformations
username: z.string()
.trim()
.toLowerCase()
.min(3, 'At least 3 characters'),
// Custom transformation
slug: z.string().transform(val =>
val.toLowerCase().replace(/\s+/g, '-')
)
});
// Example usage
const result = schema.parse({
name: ' John Doe ',
email: 'JOHN@EXAMPLE.COM',
code: 'abc123',
username: ' JohnDoe ',
slug: 'My Blog Post'
});
console.log(result);
// {
// name: 'John Doe',
// email: 'john@example.com',
// code: 'ABC123',
// username: 'johndoe',
// slug: 'my-blog-post'
// }
✅ String Validation Best Practices
- Always trim strings to remove accidental whitespace
- Use built-in validators (email, url) instead of custom regex when possible
- Provide clear, specific error messages
- Consider case sensitivity (toLowerCase for emails)
- Validate format AND length (e.g., email + min length)
- Use transformations to normalize data
Common String Validation Patterns
Here are ready-to-use validation patterns for common scenarios:
import { z } from 'zod';
// Common patterns
const commonPatterns = {
// Username (alphanumeric + underscore, 3-20 chars)
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
// Password (strong - 8+ chars, uppercase, lowercase, number, special)
strongPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
// US Phone number
usPhone: z.string()
.regex(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
// US Zip code (5 or 9 digits)
usZip: z.string()
.regex(/^\d{5}(-\d{4})?$/, 'Invalid zip code'),
// Credit card number (simple format check)
creditCard: z.string()
.regex(/^\d{4}-\d{4}-\d{4}-\d{4}$/, 'Invalid credit card format'),
// URL slug (lowercase, hyphens, numbers)
slug: z.string()
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
// Hex color code
hexColor: z.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color (e.g., #FF5733)'),
// ISO Date string
isoDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
};
// Example usage in a form
const formSchema = z.object({
username: commonPatterns.username,
password: commonPatterns.strongPassword,
phone: commonPatterns.usPhone,
zipCode: commonPatterns.usZip
});
🏋️ Exercise 3: String Validation Practice
Task: Create a contact form with advanced string validation.
Requirements:
- Name: required, trimmed, 2-50 characters, starts with capital letter
- Email: required, valid email format, lowercase
- Phone: required, US format (123-456-7890)
- Website: optional, valid URL
- Message: required, 10-500 characters, trimmed
- Use proper error messages for each validation
💡 Hint
Chain validations: .trim().min().max().regex(). Use .toLowerCase() for email. Use .url() for website. Make website optional with .optional().
✅ Solution
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const contactSchema = z.object({
name: z.string()
.trim()
.min(1, 'Name is required')
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name cannot exceed 50 characters')
.regex(/^[A-Z]/, 'Name must start with a capital letter'),
// Clean the value first, THEN validate it as an email (.pipe chains schemas)
email: z.string()
.trim()
.toLowerCase()
.pipe(z.email('Invalid email address')),
phone: z.string()
.regex(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
website: z.url('Invalid URL')
.optional()
.or(z.literal('')), // Allow empty string
message: z.string()
.trim()
.min(10, 'Message must be at least 10 characters')
.max(500, 'Message cannot exceed 500 characters')
});
type ContactFormData = z.infer<typeof contactSchema>;
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema)
});
const onSubmit = (data: ContactFormData) => {
console.log('Contact form data:', data);
alert('Message sent successfully!');
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Contact Us</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name">Name *</label>
<input
id="name"
{...register('name')}
placeholder="John Doe"
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
}}
/>
{errors.name && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.name.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="john@example.com"
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="phone">Phone *</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="123-456-7890"
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.phone ? '#f56565' : '#ddd'}`
}}
/>
{errors.phone && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.phone.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="website">Website (Optional)</label>
<input
id="website"
type="url"
{...register('website')}
placeholder="https://example.com"
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.website ? '#f56565' : '#ddd'}`
}}
/>
{errors.website && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.website.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="message">Message *</label>
<textarea
id="message"
{...register('message')}
rows={5}
placeholder="Tell us what you need..."
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
fontFamily: 'inherit',
border: `1px solid ${errors.message ? '#f56565' : '#ddd'}`
}}
/>
{errors.message && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.message.message}
</span>
)}
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Send Message
</button>
</form>
);
}
export default ContactForm;
🔢 Number and Date Validation
Zod provides powerful validation for numbers and dates. Let's explore the options.
Number Validation Methods
import { z } from 'zod';
const schema = z.object({
// Greater than (exclusive)
age: z.number().gt(0, 'Age must be greater than 0'),
// Greater than or equal (inclusive)
rating: z.number().gte(1, 'Rating must be at least 1'),
// Less than (exclusive)
discount: z.number().lt(100, 'Discount must be less than 100'),
// Less than or equal (inclusive)
percentage: z.number().lte(100, 'Percentage cannot exceed 100'),
// Positive (> 0)
price: z.number().positive('Price must be positive'),
// Non-negative (>= 0)
quantity: z.number().nonnegative('Quantity cannot be negative'),
// Negative (< 0)
temperature: z.number().negative('Temperature must be negative'),
// Non-positive (<= 0)
debt: z.number().nonpositive('Debt cannot be positive'),
// Multiple of (divisible by)
stepValue: z.number().multipleOf(5, 'Must be a multiple of 5'),
// Integer only
count: z.number().int('Must be a whole number'),
// Finite (not Infinity or NaN)
score: z.number().finite('Must be a finite number'),
// Safe integer (within JavaScript safe range)
id: z.number().safe('Number too large')
});
Number Range Validation
const schema = z.object({
// Between two values (inclusive)
age: z.number()
.min(18, 'Must be at least 18')
.max(120, 'Must be at most 120'),
// Rating system (1-5)
rating: z.number()
.int('Rating must be a whole number')
.min(1, 'Minimum rating is 1')
.max(5, 'Maximum rating is 5'),
// Percentage (0-100)
progress: z.number()
.min(0, 'Cannot be negative')
.max(100, 'Cannot exceed 100'),
// Price (positive, max 2 decimals)
price: z.number()
.positive('Price must be positive')
.multipleOf(0.01, 'Price can have at most 2 decimal places')
.max(999999.99, 'Price is too high'),
// Quantity (positive integer)
quantity: z.number()
.int('Quantity must be a whole number')
.positive('Quantity must be positive')
.max(9999, 'Quantity cannot exceed 9999')
});
Number with React Hook Form
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const productSchema = z.object({
name: z.string().min(1, 'Product name is required'),
price: z.number({ error: 'Price must be a number' })
.positive('Price must be positive')
.multipleOf(0.01, 'Price can have at most 2 decimal places'),
quantity: z.number({ error: 'Quantity must be a number' })
.int('Quantity must be a whole number')
.positive('Quantity must be positive'),
discount: z.number()
.min(0, 'Discount cannot be negative')
.max(100, 'Discount cannot exceed 100%')
.optional()
.default(0)
});
type ProductFormData = z.infer<typeof productSchema>;
function ProductForm() {
const { register, handleSubmit, formState: { errors } } = useForm<ProductFormData>({
resolver: zodResolver(productSchema)
});
const onSubmit = (data: ProductFormData) => {
console.log('Product data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Product Name</label>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<label>Price ($)</label>
<input
type="number"
step="0.01"
{...register('price', { valueAsNumber: true })}
/>
{errors.price && <span>{errors.price.message}</span>}
</div>
<div>
<label>Quantity</label>
<input
type="number"
{...register('quantity', { valueAsNumber: true })}
/>
{errors.quantity && <span>{errors.quantity.message}</span>}
</div>
<div>
<label>Discount (%)</label>
<input
type="number"
step="1"
{...register('discount', { valueAsNumber: true })}
/>
{errors.discount && <span>{errors.discount.message}</span>}
</div>
<button type="submit">Add Product</button>
</form>
);
}
⚠️ Critical: valueAsNumber
Always use valueAsNumber: true with number inputs:
// ❌ Wrong - Zod receives string "42"
<input type="number" {...register('age')} />
// ✅ Correct - Zod receives number 42
<input type="number" {...register('age', { valueAsNumber: true })} />
Without valueAsNumber, HTML inputs return strings and Zod validation will fail!
Date Validation
import { z } from 'zod';
const schema = z.object({
// Basic date
birthDate: z.date(),
// Date with min (must be after)
startDate: z.date()
.min(new Date('2024-01-01'), 'Start date must be after Jan 1, 2024'),
// Date with max (must be before)
endDate: z.date()
.max(new Date('2025-12-31'), 'End date must be before Dec 31, 2025'),
// Date range
eventDate: z.date()
.min(new Date(), 'Event date must be in the future')
.max(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), 'Event date must be within 1 year'),
// Age validation (must be 18+ years old)
dateOfBirth: z.date()
.refine(date => {
const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
return age >= 18;
}, {
error: 'You must be at least 18 years old'
})
});
Date with React Hook Form
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventSchema = z.object({
eventName: z.string().min(1, 'Event name is required'),
startDate: z.date({ error: 'A valid start date is required' })
.min(today, 'Start date must be today or later'),
endDate: z.date({ error: 'A valid end date is required' })
}).refine(data => data.endDate >= data.startDate, {
error: 'End date must be after or equal to start date',
path: ['endDate']
});
type EventFormData = z.infer<typeof eventSchema>;
function EventForm() {
const { register, handleSubmit, formState: { errors } } = useForm<EventFormData>({
resolver: zodResolver(eventSchema)
});
const onSubmit = (data: EventFormData) => {
console.log('Event data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Event Name</label>
<input {...register('eventName')} />
{errors.eventName && <span>{errors.eventName.message}</span>}
</div>
<div>
<label>Start Date</label>
<input
type="date"
{...register('startDate', { valueAsDate: true })}
/>
{errors.startDate && <span>{errors.startDate.message}</span>}
</div>
<div>
<label>End Date</label>
<input
type="date"
{...register('endDate', { valueAsDate: true })}
/>
{errors.endDate && <span>{errors.endDate.message}</span>}
</div>
<button type="submit">Create Event</button>
</form>
);
}
⚠️ Critical: valueAsDate
Always use valueAsDate: true with date inputs:
// ❌ Wrong - Zod receives string "2024-01-01"
<input type="date" {...register('birthDate')} />
// ✅ Correct - Zod receives Date object
<input type="date" {...register('birthDate', { valueAsDate: true })} />
Working with Date Strings
Sometimes you need to work with date strings instead of Date objects:
import { z } from 'zod';
// Validate ISO date string
const schema = z.object({
// ISO 8601 datetime string (Zod 4: z.iso.datetime, not z.string().datetime)
createdAt: z.iso.datetime(),
// Just the calendar date, no time component
publishedOn: z.iso.date(), // '2026-08-06'
// Custom date string format
birthDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
.refine(dateStr => {
const date = new Date(dateStr);
return !isNaN(date.getTime());
}, {
error: 'Invalid date'
}),
// Transform date string to Date object
eventDate: z.string()
.transform(str => new Date(str))
.pipe(z.date().min(new Date(), 'Date must be in the future'))
});
Coercion for Flexibility
Use Zod's coerce feature to automatically convert types:
const schema = z.object({
// Coerce string to number
age: z.coerce.number()
.int()
.min(18, 'Must be at least 18'),
// Coerce string to date
birthDate: z.coerce.date()
.max(new Date(), 'Birth date cannot be in the future'),
// Coerce to boolean
acceptTerms: z.coerce.boolean()
});
// Now you don't need valueAsNumber or valueAsDate!
function FlexibleForm() {
const { register } = useForm({
resolver: zodResolver(schema)
});
return (
<form>
{/* These work without valueAsNumber/valueAsDate */}
<input type="number" {...register('age')} />
<input type="date" {...register('birthDate')} />
<input type="checkbox" {...register('acceptTerms')} />
</form>
);
}
💡 When to Use Coercion
- Use coerce - For API data or when types might vary
- Use explicit types - For strict validation and type safety
- Recommendation - Use explicit types with valueAsNumber/valueAsDate for forms (clearer intent)
🔷 TypeScript Type Inference
One of Zod's superpowers is automatic TypeScript type inference. Let's explore how it works.
Basic Type Inference
import { z } from 'zod';
// Define a schema
const userSchema = z.object({
name: z.string(),
age: z.number(),
email: z.email()
});
// Infer the TypeScript type
type User = z.infer<typeof userSchema>;
// Result is equivalent to:
// type User = {
// name: string;
// age: number;
// email: string;
// }
// Use the inferred type
const user: User = {
name: 'Alice',
age: 30,
email: 'alice@example.com'
};
Inference with Optional and Nullable
const schema = z.object({
required: z.string(),
optional: z.string().optional(),
nullable: z.string().nullable(),
withDefault: z.string().default('default'),
optionalNullable: z.string().optional().nullable()
});
type InferredType = z.infer<typeof schema>;
// Result:
// type InferredType = {
// required: string;
// optional?: string | undefined;
// nullable: string | null;
// withDefault: string;
// optionalNullable?: string | null | undefined;
// }
Inference with Arrays and Objects
const schema = z.object({
tags: z.array(z.string()),
scores: z.array(z.number()),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string()
}),
contacts: z.array(z.object({
name: z.string(),
email: z.email()
}))
});
type InferredType = z.infer<typeof schema>;
// Result:
// type InferredType = {
// tags: string[];
// scores: number[];
// address: {
// street: string;
// city: string;
// zipCode: string;
// };
// contacts: Array<{
// name: string;
// email: string;
// }>;
// }
Inference with Enums and Unions
const schema = z.object({
role: z.enum(['admin', 'user', 'guest']),
status: z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending')
]),
identifier: z.union([z.string(), z.number()])
});
type InferredType = z.infer<typeof schema>;
// Result:
// type InferredType = {
// role: 'admin' | 'user' | 'guest';
// status: 'active' | 'inactive' | 'pending';
// identifier: string | number;
// }
Input vs Output Types
Zod schemas can have different input and output types (e.g., with transforms):
const schema = z.object({
name: z.string().trim(), // Transform: trims whitespace
email: z.string().toLowerCase(), // Transform: converts to lowercase
age: z.string().transform(val => parseInt(val)) // Transform: string to number
});
// Input type (what goes in)
type Input = z.input<typeof schema>;
// Result: { name: string; email: string; age: string; }
// Output type (what comes out after validation)
type Output = z.output<typeof schema>;
// Result: { name: string; email: string; age: number; }
// z.infer is an alias for z.output
type InferredOutput = z.infer<typeof schema>;
// Same as Output
Extracting Nested Types
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string()
});
const userSchema = z.object({
name: z.string(),
address: addressSchema
});
// Extract the entire user type
type User = z.infer<typeof userSchema>;
// Extract just the address type
type Address = z.infer<typeof addressSchema>;
// Or extract from nested schema
type AddressFromUser = User['address'];
Reusing Schema Pieces
// Define reusable schemas
const emailSchema = z.email();
const passwordSchema = z.string().min(8);
const phoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/);
// Combine into different forms
const loginSchema = z.object({
email: emailSchema,
password: passwordSchema
});
const registrationSchema = z.object({
email: emailSchema,
password: passwordSchema,
phone: phoneSchema,
name: z.string()
});
// Both share the same email and password validation!
type LoginData = z.infer<typeof loginSchema>;
type RegistrationData = z.infer<typeof registrationSchema>;
✅ Type Inference Benefits
- No duplicate definitions - Schema defines both validation and types
- Always in sync - Change schema, types update automatically
- Reduce errors - Impossible for types and validation to diverge
- Better refactoring - TypeScript catches all affected code
- Self-documenting - Schema shows exactly what's valid
- Shared schemas - Use same schema for frontend and backend
Advanced Pattern: Extending Schemas
// Base schema
const baseUserSchema = z.object({
name: z.string(),
email: z.email()
});
// Extend with more fields
const adminSchema = baseUserSchema.extend({
role: z.literal('admin'),
permissions: z.array(z.string())
});
// Merge schemas
const userWithAddressSchema = baseUserSchema.merge(
z.object({
address: z.object({
street: z.string(),
city: z.string()
})
})
);
// Pick specific fields
const emailOnlySchema = baseUserSchema.pick({ email: true });
// Omit specific fields
const nameOnlySchema = baseUserSchema.omit({ email: true });
// Make all fields optional
const partialUserSchema = baseUserSchema.partial();
// Make all fields required
const requiredUserSchema = partialUserSchema.required();
// Infer types from all variants
type Admin = z.infer<typeof adminSchema>;
type UserWithAddress = z.infer<typeof userWithAddressSchema>;
type EmailOnly = z.infer<typeof emailOnlySchema>;
type PartialUser = z.infer<typeof partialUserSchema>;
💡 Schema Manipulation Methods
| Method | Purpose | Example |
|---|---|---|
.extend() |
Add new fields | Add role to user |
.merge() |
Combine schemas | Merge user + address |
.pick() |
Select specific fields | Email-only form |
.omit() |
Exclude fields | Remove password field |
.partial() |
Make all optional | Update forms |
.required() |
Make all required | Strict validation |
💬 Custom Error Messages
Zod makes it easy to customize error messages for better user experience.
Per-Validation Messages
const schema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed'),
email: z.email('Please enter a valid email address'),
age: z.number({
error: (issue) =>
issue.input === undefined ? 'Age is required' : 'Age must be a number'
})
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age')
});
Global Custom Errors
Instead of writing the same message on every field, you can register one error function that Zod consults for every validation. In Zod 4 this replaces the old z.setErrorMap/ZodErrorMap API: you pass a customError function to z.config(). The function receives the issue and returns a string—or undefined to let the next error source (a per-field message, then Zod's default) take over.
import { z } from 'zod';
// Zod 4: register a global error via z.config({ customError })
z.config({
customError: (issue) => {
if (issue.code === 'invalid_type') {
if (issue.expected === 'string') return 'This field must be text';
if (issue.expected === 'number') return 'This field must be a number';
}
// In Zod 4, size issues carry `origin` ('string' | 'number' | 'array' | ...)
if (issue.code === 'too_small' && issue.origin === 'string') {
return `Minimum ${issue.minimum} characters required`;
}
if (issue.code === 'too_big' && issue.origin === 'string') {
return `Maximum ${issue.maximum} characters allowed`;
}
// Return undefined to fall back to the default message
return undefined;
}
});
// You can also scope an error function to a single schema via the `error` param
const schema = z.object({
name: z.string().min(3)
}, {
error: (issue) => 'Please check the highlighted fields'
});
💡 The error-precedence ladder
When several messages could apply, Zod 4 picks in this order (most specific wins): the per-check message you passed (e.g. .min(8, 'Too short')) → the per-schema error → the global z.config error → Zod's built-in default. That's why a global error function usually returns undefined for cases it doesn't care about—so your specific messages still win.
Error Messages with Field Names
const createSchema = (fieldName: string) => {
return z.string()
.min(1, `${fieldName} is required`)
.min(3, `${fieldName} must be at least 3 characters`);
};
const schema = z.object({
firstName: createSchema('First name'),
lastName: createSchema('Last name'),
city: createSchema('City')
});
// Errors will be:
// "First name is required"
// "Last name must be at least 3 characters"
// "City is required"
Internationalization (i18n)
// Error messages in different languages
const errorMessages = {
en: {
required: 'This field is required',
email: 'Invalid email address',
minLength: (min: number) => `Minimum ${min} characters required`
},
es: {
required: 'Este campo es obligatorio',
email: 'Dirección de correo electrónico no válida',
minLength: (min: number) => `Mínimo ${min} caracteres requeridos`
}
};
const createLocalizedSchema = (lang: 'en' | 'es') => {
const msg = errorMessages[lang];
return z.object({
email: z.string()
.min(1, msg.required)
.email(msg.email),
password: z.string()
.min(1, msg.required)
.min(8, msg.minLength(8))
});
};
// Use with current language
const schema = createLocalizedSchema('en'); // or 'es'
Contextual Error Messages
const schema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number')
.regex(/[^A-Za-z0-9]/, 'Password must contain a special character'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword'] // Show error on confirmPassword field
}).refine(data => data.password !== 'password123', {
error: 'This password is too common. Please choose a different one.',
path: ['password']
});
refine vs superRefine
.refine() is perfect for a single yes/no check with one message. But sometimes one rule needs to raise several errors, on different fields, with different messages—that's where .superRefine() earns its keep. It hands you the value plus a ctx you can call ctx.addIssue() on as many times as you like:
const signupSchema = z.object({
password: z.string(),
confirmPassword: z.string()
}).superRefine((data, ctx) => {
// Raise a rich, targeted issue for each problem we find
if (data.password.length < 8) {
ctx.addIssue({
code: 'custom',
message: 'Password must be at least 8 characters',
path: ['password']
});
}
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: 'custom',
message: 'Passwords do not match',
path: ['confirmPassword']
});
}
});
💡 Which refinement should I use?
.refine()- one condition, one message. The 90% case (password confirmation, "must be 18+")..superRefine()- multiple conditional issues, custom issuecodes, or errors spread across several fields in a single pass.- Both run after the basic field types pass, so inside them your data is already typed.
✅ Error Message Best Practices
- Be specific - "Email is required" not "Required"
- Be helpful - Tell users how to fix the error
- Be concise - Keep messages short and clear
- Be polite - Use friendly, non-accusatory language
- Show examples - "Format: 123-456-7890" for phone numbers
- Use plain language - Avoid technical jargon
- Be consistent - Use similar wording across forms
Error Message Examples
❌ Poor Error Messages
- "Invalid" - What's invalid?
- "Error in field" - Which field? What error?
- "Must match pattern /^[A-Z]/" - Too technical
- "Required" - Which field is required?
✅ Good Error Messages
- "Email address is required"
- "Password must be at least 8 characters"
- "Name must start with a capital letter"
- "Phone number format: 123-456-7890"
📚 Summary
Congratulations! You've mastered Zod and learned how to create powerful, type-safe forms. Let's recap what you've learned:
Key Takeaways
- ✅ Schema-based validation - Define validation once, use everywhere
- ✅ Type inference - Automatic TypeScript types from schemas
- ✅ React Hook Form integration - Seamless with zodResolver
- ✅ Rich validation - 40+ built-in validators for all types
- ✅ Composable schemas - Build complex schemas from simple ones
- ✅ Custom error messages - User-friendly, localized errors
- ✅ Transformations - Clean and normalize data automatically
- ✅ Cross-field validation - Validate fields against each other
Zod vs React Hook Form Inline Validation
| Feature | Inline Validation | Zod + React Hook Form |
|---|---|---|
| Code location | Mixed with UI | Separate schema |
| Reusability | Copy-paste | Import and use |
| TypeScript types | Manual definition | Auto-inferred |
| Validation logic | Per field, scattered | Centralized schema |
| Cross-field validation | Complex with watch() | Simple with refine() |
| API validation | Separate logic | Same schema |
| Transformations | Manual in submit | Built into schema |
| Best for | Simple forms | Complex forms |
Complete Example: Everything Together
Here's a comprehensive example showing all the concepts you've learned:
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. Reusable schema pieces
const emailSchema = z.email('Invalid email address').toLowerCase();
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character');
// 2. Nested schemas
const addressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
zipCode: z.string().regex(/^\d{5}$/, 'Invalid zip code'),
country: z.enum(['US', 'CA', 'UK', 'AU'])
});
// 3. Main form schema with all features
const userRegistrationSchema = z.object({
// Basic string validation
firstName: z.string()
.trim()
.min(1, 'First name is required')
.min(2, 'At least 2 characters')
.max(50, 'Maximum 50 characters'),
lastName: z.string()
.trim()
.min(1, 'Last name is required')
.min(2, 'At least 2 characters')
.max(50, 'Maximum 50 characters'),
// Reusable schema
email: emailSchema,
password: passwordSchema,
confirmPassword: z.string(),
// Number validation
age: z.number({
error: (issue) =>
issue.input === undefined ? 'Age is required' : 'Age must be a number'
})
.int('Age must be a whole number')
.min(18, 'Must be at least 18 years old')
.max(120, 'Please enter a valid age'),
// Date validation
birthDate: z.date()
.max(new Date(), 'Birth date cannot be in the future')
.refine(date => {
const age = Math.floor((Date.now() - date.getTime()) / (365.25 * 24 * 60 * 60 * 1000));
return age >= 18;
}, 'Must be at least 18 years old'),
// Nested schema
address: addressSchema,
// Array validation
interests: z.array(z.string())
.min(1, 'Select at least one interest')
.max(5, 'Maximum 5 interests'),
// Enum validation
role: z.enum(['user', 'admin', 'moderator']),
// Boolean validation
terms: z.boolean()
.refine(val => val === true, 'You must accept the terms'),
// Optional fields
bio: z.string()
.max(500, 'Bio cannot exceed 500 characters')
.optional(),
// Field with default
notifications: z.boolean().default(true)
})
// 4. Cross-field validation
.refine(data => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword']
});
// 5. Infer TypeScript type
type UserRegistrationData = z.infer<typeof userRegistrationSchema>;
// 6. Create form component
function UserRegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<UserRegistrationData>({
resolver: zodResolver(userRegistrationSchema),
mode: 'onTouched'
});
const onSubmit = async (data: UserRegistrationData) => {
console.log('Validated data:', data);
// Data is fully validated and typed!
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Registration successful!');
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* All fields would go here */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
);
}
✅ Best Practices Checklist
- ✅ Define schemas outside components (avoid recreation on render)
- ✅ Use z.infer for automatic TypeScript types
- ✅ Create reusable schema pieces for common patterns
- ✅ Use meaningful, user-friendly error messages
- ✅ Use transformations (trim, toLowerCase) to normalize data
- ✅ Use mode: 'onTouched' for best UX
- ✅ Use valueAsNumber for number inputs
- ✅ Use valueAsDate for date inputs
- ✅ Test schemas with sample data before using in production
- ✅ Use refine() for cross-field validation
- ✅ Compose complex schemas from simpler ones
- ✅ Share schemas between frontend and backend
Common Patterns Quick Reference
// Required string
z.string().min(1, 'Required')
// Email / URL / UUID (Zod 4 top-level formats)
z.email('Invalid email')
z.url()
z.uuid()
// ISO date/time
z.iso.datetime()
// Password (strong)
z.string()
.min(8)
.regex(/[A-Z]/)
.regex(/[a-z]/)
.regex(/[0-9]/)
// Number range
z.number().min(1).max(100)
// Positive integer
z.number().int().positive()
// Date in future
z.date().min(new Date())
// Optional field
z.string().optional()
// Field with default
z.string().default('default')
// Enum
z.enum(['option1', 'option2'])
// Array with limits
z.array(z.string()).min(1).max(5)
// Nested object
z.object({
nested: z.object({
field: z.string()
})
})
// Cross-field validation
schema.refine(data => data.password === data.confirm, {
error: 'Must match',
path: ['confirm']
})
// Transform
z.string().trim().toLowerCase()
// Coerce types
z.coerce.number()
z.coerce.date()
// Union / discriminated union
z.union([z.string(), z.number()])
z.discriminatedUnion('type', [/* ...tagged objects */])
// Regex validation
z.string().regex(/pattern/, 'Message')
// Read errors (Zod 4)
result.error.issues // raw array
z.treeifyError(result.error) // nested, mirrors schema
z.prettifyError(result.error) // printable string
When to Use Zod
✅ Perfect For:
- Forms with 5+ fields
- Complex validation rules
- Cross-field dependencies
- TypeScript projects (type inference is amazing!)
- Reusable validation across multiple forms
- API request/response validation
- Full-stack apps (share schemas between FE and BE)
- When validation logic needs to be centralized
- Production applications requiring maintainability
⚠️ Consider Simpler Solutions When:
- Form has 1-3 fields with basic validation
- Validation rules are extremely simple (just "required")
- One-off form that won't be reused
- Prototype where requirements are changing rapidly
- Team unfamiliar with schema validation
- Project not using TypeScript (lose main benefit)
Troubleshooting Common Issues
Problem: "Expected number, received string"
Solution: Use valueAsNumber: true on number inputs
<input type="number" {...register('age', { valueAsNumber: true })} />
Problem: "Expected date, received string"
Solution: Use valueAsDate: true on date inputs
<input type="date" {...register('date', { valueAsDate: true })} />
Problem: Schema recreated on every render
Solution: Define schema outside component
// ❌ Wrong - inside component
function MyForm() {
const schema = z.object({ ... });
}
// ✅ Correct - outside component
const schema = z.object({ ... });
function MyForm() { ... }
Problem: Optional field shows "Required" error
Solution: Use .optional() or accept empty string
// For truly optional
bio: z.string().optional()
// For optional but allow empty string
website: z.url().optional().or(z.literal(''))
Problem: Cross-field validation error on wrong field
Solution: Specify path in refine
.refine(data => data.password === data.confirm, {
error: 'Passwords must match',
path: ['confirm'] // Show error on confirm field
})
🎯 What's Next?
In the upcoming lessons, you'll learn:
- Lesson 7.4: File Uploads - Handle file inputs, image previews, and validation
- Lesson 7.5: Advanced Form Patterns - Dynamic field arrays, multi-step wizards, conditional fields
- Module Project - Build a complete user registration system with everything you've learned
You now have professional-grade form validation skills!
💪 You've Mastered
- Schema-based validation with Zod
- Seamless integration with React Hook Form
- TypeScript type inference from schemas
- All Zod primitive types (string, number, date, etc.)
- Advanced string validation (email, URL, regex patterns)
- Number and date validation with ranges
- Optional, nullable, and default values
- Cross-field validation with refine()
- Schema composition and reusability
- Custom error messages and localization
- Data transformations and normalization
- Schema manipulation (extend, merge, pick, omit)
You're now ready to build production-quality forms with confidence!
📖 Additional Resources
🔥 Pro Tips
- Share schemas - Use the same Zod schemas on frontend and backend
- Test schemas - Write unit tests for complex validation logic
- Document schemas - Schemas are self-documenting but add comments for complex rules
- Version schemas - When APIs evolve, version your schemas
- Error messages matter - Invest time in clear, helpful error messages
- Colocate schemas - Keep schemas near the forms that use them
- Use TypeScript strict mode - Catch more errors at compile time
- Leverage transformations - Clean data automatically (trim, lowercase)
- Build a schema library - Create reusable schemas for your app
- Monitor bundle size - Zod is small but tree-shake unused validators
🎉 Congratulations!
You've completed Lesson 7.3: Form Validation with Zod
You now have the skills to build professional, type-safe forms that are maintainable, reusable, and user-friendly. Combined with React Hook Form, you have one of the most powerful form solutions available in the React ecosystem!