π‘οΈ 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';
const formSchema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Invalid email format')
.min(5, 'Email must be at least 5 characters'),
password: z.string()
.min(1, 'Password is required')
.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
- Tiny bundle - Only 8KB minified + gzipped
What is Zod?
Zod is a TypeScript-first schema validation library. Think of it as a way to define the "shape" of your dataβwhat fields exist, what types they are, and what rules they must followβall in one place.
π 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 | Yup | Joi |
|---|---|---|---|
| TypeScript | Native, first-class | Via types package | Limited support |
| Type inference | Automatic β | Manual | Manual |
| Bundle size | 8KB | 15KB | Not browser-friendly |
| API style | Chainable, modern | Chainable | Object-based |
| Learning curve | Easy | Easy | Moderate |
| Popularity | Growing fast π | Established | Server-focused |
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, Yup, Joi) to React Hook Form. It tells React Hook Form:
- How to validate form data using your schema
- How to format error messages
- When to trigger validation
The @hookform/resolvers package includes resolvers for all major validation libraries.
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
const schema = z.object({
email: z.string().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 - β 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.string()
.min(1, 'Email is required')
.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 data (throws error if invalid).safeParse(data)- Validate and return {success, data} or {success, error}.parseAsync(data)- Async version of parse.partial()- Make all fields optional.required()- Make all fields required.extend()- Add more fields to schema
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
const step2 = z.object({
username: z.string().min(3).max(20),
email: z.string().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.string()
.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.string()
.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.string()
.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, {
message: '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.string().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.string().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) {
console.log('Validation errors:');
result2.error.issues.forEach(issue => {
console.log(`${issue.path.join('.')}: ${issue.message}`);
});
}
// Output:
// email: Invalid email
// age: Number must be greater than or equal to 18
β 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.string()
.min(1, 'Email is required')
.email('Invalid email address'),
age: z.number({
required_error: 'Age is required',
invalid_type_error: '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, {
message: 'You must accept the terms and conditions'
})
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
// 2. Infer TypeScript type
type RegistrationFormData = z.infer;
// 3. Create the form component
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
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({
resolver: zodResolver(schema),
mode: 'onSubmit'
});
// Validate on blur (good UX)
const { register } = useForm({
resolver: zodResolver(schema),
mode: 'onBlur'
});
// Validate on change (real-time feedback)
const { register } = useForm({
resolver: zodResolver(schema),
mode: 'onChange'
});
// Validate on blur, then on change (best UX)
const { register } = useForm({
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;
function NumberDateForm() {
const { register, handleSubmit } = useForm({
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.string()
.min(1, 'Email is required')
.email('Invalid email address'),
age: z.number({
required_error: 'Age is required',
invalid_type_error: '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;
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
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
email: z.string().email('Invalid email'),
// URL validation
website: z.string().url('Invalid URL'),
// UUID validation
id: z.string().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, {
message: '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.string().email(),
address: addressSchema, // Nested object
// Object with dynamic keys
metadata: z.record(z.string(), z.any()),
// 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
status: z.enum(['active', 'inactive', 'pending'], {
errorMap: () => ({ message: 'Invalid status' })
}),
// From TypeScript enum
priority: z.nativeEnum(Priority) // where Priority is a TS enum
});
// TypeScript enum example
enum Priority {
Low = 'low',
Medium = 'medium',
High = 'high'
}
const taskSchema = z.object({
title: z.string(),
priority: z.nativeEnum(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
contact: z.union([
z.object({ type: z.literal('email'), email: z.string().email() }),
z.object({ type: z.literal('phone'), phone: z.string() })
])
});
π‘ 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
const schema = z.object({
// Email validation
email: z.string().email('Invalid email address'),
// URL validation
website: z.string().url('Invalid URL'),
// UUID validation
id: z.string().uuid('Invalid UUID'),
// CUID validation
userId: z.string().cuid('Invalid CUID'),
// CUID2 validation
sessionId: z.string().cuid2('Invalid CUID2'),
// ULID validation
transactionId: z.string().ulid('Invalid ULID'),
// ISO datetime validation
createdAt: z.string().datetime('Invalid datetime'),
// IP address validation
ipAddress: z.string().ip('Invalid IP address'),
// IPv4 specific
ipv4: z.string().ip({ version: 'v4', message: 'Invalid IPv4' }),
// IPv6 specific
ipv6: z.string().ip({ version: 'v6', message: 'Invalid IPv6' })
});
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'),
email: z.string()
.trim()
.min(1, 'Email is required')
.email('Invalid email address')
.toLowerCase(),
phone: z.string()
.regex(/^\d{3}-\d{3}-\d{4}$/, 'Phone must be in format: 123-456-7890'),
website: z.string()
.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({
required_error: 'Price is required',
invalid_type_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({
required_error: 'Quantity is required',
invalid_type_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;
}, {
message: '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({
required_error: 'Start date is required',
invalid_type_error: 'Invalid date'
})
.min(today, 'Start date must be today or later'),
endDate: z.date({
required_error: 'End date is required',
invalid_type_error: 'Invalid date'
})
}).refine(data => data.endDate >= data.startDate, {
message: '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
createdAt: z.string().datetime(),
// 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());
}, {
message: '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.string().email()
});
// Infer the TypeScript type
type User = z.infer;
// 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;
// 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.string().email()
}))
});
type InferredType = z.infer;
// 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;
// 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;
// Result: { name: string; email: string; age: string; }
// Output type (what comes out after validation)
type Output = z.output;
// Result: { name: string; email: string; age: number; }
// z.infer is an alias for z.output
type InferredOutput = z.infer;
// 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;
// Extract just the address type
type Address = z.infer;
// Or extract from nested schema
type AddressFromUser = User['address'];
Reusing Schema Pieces
// Define reusable schemas
const emailSchema = z.string().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;
type RegistrationData = z.infer;
β 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.string().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;
type UserWithAddress = z.infer;
type EmailOnly = z.infer;
type PartialUser = z.infer;
π‘ 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.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
age: z.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number'
})
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age')
});
Custom Error Maps
Override default error messages globally:
import { z } from 'zod';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'This field must be text' };
}
if (issue.expected === 'number') {
return { message: 'This field must be a number' };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `Minimum ${issue.minimum} characters required` };
}
}
if (issue.code === z.ZodIssueCode.too_big) {
if (issue.type === 'string') {
return { message: `Maximum ${issue.maximum} characters allowed` };
}
}
// Fallback to default message
return { message: ctx.defaultError };
};
// Use custom error map
z.setErrorMap(customErrorMap);
// Or use per-schema
const schema = z.object({
name: z.string().min(3)
}, { errorMap: customErrorMap });
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, {
message: 'Passwords do not match',
path: ['confirmPassword'] // Show error on confirmPassword field
}).refine(data => data.password !== 'password123', {
message: 'This password is too common. Please choose a different one.',
path: ['password']
});
β 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.string()
.min(1, 'Email is required')
.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({
required_error: 'Age is required',
invalid_type_error: '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, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
// 5. Infer TypeScript type
type UserRegistrationData = z.infer;
// 6. Create form component
function UserRegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
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 (
);
}
β 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
z.string().email('Invalid email')
// 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, {
message: 'Must match',
path: ['confirm']
})
// Transform
z.string().trim().toLowerCase()
// Coerce types
z.coerce.number()
z.coerce.date()
// Union
z.union([z.string(), z.number()])
// Regex validation
z.string().regex(/pattern/, 'Message')
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.string().url().optional().or(z.literal(''))
Problem: Cross-field validation error on wrong field
Solution: Specify path in refine
.refine(data => data.password === data.confirm, {
message: '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!