Type-Safe React: Leveraging TypeScript and Zod for Better Applications
TypeScript and Zod together create a powerful combination for building type-safe React applications. While TypeScript provides static type checking, Zod adds runtime validation and automatic type inference. Let's explore how to effectively use these tools together to build robust React applications.
Why Combine TypeScript and Zod?
Before diving into the implementation, let's understand why this combination is powerful:
Single Source of Truth: Define your schema once with Zod and derive TypeScript types automatically
Runtime Validation: Zod validates data at runtime, while TypeScript handles compile-time type checking
Type Inference: Automatically generate TypeScript types from Zod schemas
Better API Integration: Validate API responses and ensure type safety throughout your application
First, let's install the required dependencies:
npm install zod
# or
yarn add zod
Basic Schema and Type Definition
Let's start with basic schema definitions and see how to derive types:
import { z } from 'zod';
// Define a user schema
const userSchema = z.object({
id: z.number(),
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
role: z.enum(['admin', 'user', 'guest']),
preferences: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
}).optional()
});
// Derive TypeScript type from the schema
type User = z.infer<typeof userSchema>;
// Now you can use this type in your React components
const UserProfile: React.FC<{ user: User }> = ({ user }) => {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
{user.age && <p>Age: {user.age}</p>}
<p>Role: {user.role}</p>
</div>
);
};
Form Handling with Zod and TypeScript
Let's create a type-safe form using Zod for validation:
import { z } from 'zod';
import { useState } from 'react';
// Define the form schema
const signupFormSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be less than 20 characters'),
email: z.string()
.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
});
// Derive the type from the schema
type SignupFormData = z.infer<typeof signupFormSchema>;
const SignupForm: React.FC = () => {
const [formData, setFormData] = useState<SignupFormData>({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
try {
const validatedData = signupFormSchema.parse(formData);
console.log('Valid form data:', validatedData);
// Process the validated data
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors: Record<string, string> = {};
error.errors.forEach(err => {
if (err.path) {
formattedErrors[err.path[0]] = err.message;
}
});
setErrors(formattedErrors);
}
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
/>
{errors.username && <span>{errors.username}</span>}
</div>
<div>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && <span>{errors.password}</span>}
</div>
<div>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Confirm Password"
/>
{errors.confirmPassword && <span>{errors.confirmPassword}</span>}
</div>
<button type="submit">Sign Up</button>
</form>
);
};
API Integration with Zod
Let's create a type-safe API integration using Zod:
import { z } from 'zod';
// Define API response schemas
const errorSchema = z.object({
message: z.string(),
code: z.number()
});
const userResponseSchema = z.object({
data: z.array(userSchema),
pagination: z.object({
total: z.number(),
page: z.number(),
limit: z.number()
})
});
// Create a custom hook for fetching users
function useUsers() {
const [users, setUsers] = useState<z.infer<typeof userResponseSchema>['data']>([]);
const [error, setError] = useState<z.infer<typeof errorSchema> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const fetchUsers = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/users');
const json = await response.json();
// Validate the response with Zod
const validated = userResponseSchema.parse(json);
setUsers(validated.data);
} catch (err) {
if (err instanceof z.ZodError) {
// Handle validation errors
setError({ message: 'Invalid API response format', code: 400 });
} else {
// Handle other errors
setError({ message: 'Failed to fetch users', code: 500 });
}
} finally {
setIsLoading(false);
}
};
return { users, error, isLoading, fetchUsers };
}
Advanced Patterns with Zod
Nested Object Validation
import { z } from 'zod';
// Define nested schemas
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
postalCode: z.string()
});
const orderItemSchema = z.object({
productId: z.number(),
quantity: z.number().min(1),
price: z.number().positive()
});
const orderSchema = z.object({
id: z.string().uuid(),
customer: userSchema,
items: z.array(orderItemSchema),
shippingAddress: addressSchema,
billingAddress: addressSchema,
total: z.number().positive(),
status: z.enum(['pending', 'processing', 'shipped', 'delivered'])
});
type Order = z.infer<typeof orderSchema>;
// Example component using the nested schema
const OrderSummary: React.FC<{ order: Order }> = ({ order }) => {
const { customer, items, total, status } = order;
return (
<div>
<h2>Order Summary</h2>
<div>
<h3>Customer</h3>
<p>{customer.name}</p>
<p>{customer.email}</p>
</div>
<div>
<h3>Items</h3>
<ul>
{items.map((item) => (
<li key={item.productId}>
Quantity: {item.quantity} - Price: ${item.price}
</li>
))}
</ul>
</div>
<div>
<p>Total: ${total}</p>
<p>Status: {status}</p>
</div>
</div>
);
};
Discriminated Unions with Zod
import { z } from 'zod';
// Define a discriminated union for different notification types
const baseNotificationSchema = z.object({
id: z.string().uuid(),
timestamp: z.date()
});
const emailNotificationSchema = baseNotificationSchema.extend({
type: z.literal('email'),
email: z.string().email(),
subject: z.string(),
body: z.string()
});
const pushNotificationSchema = baseNotificationSchema.extend({
type: z.literal('push'),
title: z.string(),
message: z.string(),
deviceId: z.string()
});
const smsNotificationSchema = baseNotificationSchema.extend({
type: z.literal('sms'),
phoneNumber: z.string(),
message: z.string()
});
const notificationSchema = z.discriminatedUnion('type', [
emailNotificationSchema,
pushNotificationSchema,
smsNotificationSchema
]);
type Notification = z.infer<typeof notificationSchema>;
const NotificationItem: React.FC<{ notification: Notification }> = ({ notification }) => {
switch (notification.type) {
case 'email':
return (
<div>
<h3>Email Notification</h3>
<p>Subject: {notification.subject}</p>
<p>To: {notification.email}</p>
</div>
);
case 'push':
return (
<div>
<h3>Push Notification</h3>
<p>Title: {notification.title}</p>
<p>Device: {notification.deviceId}</p>
</div>
);
case 'sms':
return (
<div>
<h3>SMS Notification</h3>
<p>To: {notification.phoneNumber}</p>
<p>Message: {notification.message}</p>
</div>
);
}
};
Conclusion
Combining TypeScript and Zod provides a robust foundation for building type-safe React applications. By defining schemas with Zod and deriving TypeScript types, you get the best of both worlds: compile-time type checking and runtime validation. This approach helps catch errors early in development and ensures data consistency throughout your application.
Remember these key points when working with TypeScript and Zod:
Define schemas first, then derive types using
z.infer
Use schema validation for form inputs and API responses
Leverage Zod's rich validation features for complex data structures
Combine with React's state management for complete type safety
As you build more complex applications, this combination will help maintain code quality and reduce runtime errors while providing excellent developer experience through TypeScript's type system.
Subscribe to my newsletter
Read articles from Pawan Gangwani directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Pawan Gangwani
Pawan Gangwani
I’m Pawan Gangwani, a passionate Full Stack Developer with over 12 years of experience in web development. Currently serving as a Lead Software Engineer at Lowes India, I specialize in modern web applications, particularly in React and performance optimization. I’m dedicated to best practices in coding, testing, and Agile methodologies. Outside of work, I enjoy table tennis, exploring new cuisines, and spending quality time with my family.