Skip to main content

JSON to Types: Runtime Validation With Zod

Zod is a TypeScript-first schema validation library that validates data at runtime and infers TypeScript types from validators. When you fetch JSON from an API, you can't assume it matches your expected shape. Zod let you define a schema that describes what the data should look like, then validate the actual response against it. If validation fails, you get detailed error messages. Best of all, you extract the TypeScript type from the schema itself, so your types and validation stay in sync.

How Do You Validate API Responses with Zod?

Zod works by defining a schema that describes your expected data shape. You call .parse() to validate data and throw an error if validation fails, or .safeParse() to return a result object. After validation, you extract the TypeScript type using z.infer<typeof schema>.

import { z } from 'zod';
import { useState, useEffect } from 'react';

// Define a Zod schema for a user
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
isActive: z.boolean(),
});

// Extract the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;

export const UserListWithZod = () => {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);

useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();

// Validate the response against the schema
const validatedUsers = UserSchema.array().parse(data);
setUsers(validatedUsers);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
} finally {
setLoading(false);
}
};

fetchUsers();
}, []);

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;

return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
};

The UserSchema.array().parse(data) validates that data is an array of objects matching the user shape. If the API returns invalid data (wrong types, missing fields), .parse() throws an error with details about what went wrong.

Using safeParse for Graceful Error Handling

.safeParse() returns a result object instead of throwing, making error handling more explicit:

// Define schema with optional fields
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().positive(),
description: z.string().optional(), // Optional field
inStock: z.boolean().default(true), // Default value
});

type Product = z.infer<typeof ProductSchema>;

export const ProductsWithSafeParse = () => {
const [products, setProducts] = useState<Product[]>([]);
const [errors, setErrors] = useState<string[]>([]);

useEffect(() => {
const fetchProducts = async () => {
try {
const response = await fetch('https://api.example.com/products');
const data = await response.json();

// Use safeParse to get a result object
const result = z.array(ProductSchema).safeParse(data);

if (result.success) {
setProducts(result.data);
} else {
// result.error contains detailed validation errors
const errorMessages = result.error.errors.map(
e => `${e.path.join('.')}: ${e.message}`
);
setErrors(errorMessages);
}
} catch (err) {
setErrors(['Failed to fetch products']);
}
};

fetchProducts();
}, []);

if (errors.length > 0) {
return (
<ul>
{errors.map((error, idx) => (
<li key={idx} style={{ color: 'red' }}>{error}</li>
))}
</ul>
);
}

return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
};

The result.error.errors array contains an object for each validation failure, including the field path and the reason it failed. This is useful for displaying validation errors to the user.

Composing Schemas for Complex Responses

For nested or complex API responses, compose smaller schemas:

// Nested schema: order contains user and products
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
});

const OrderItemSchema = z.object({
productId: z.number(),
quantity: z.number().int().positive(),
price: z.number().positive(),
});

const OrderSchema = z.object({
id: z.number(),
userId: z.number(),
items: z.array(OrderItemSchema),
shippingAddress: AddressSchema,
total: z.number().positive(),
createdAt: z.string().datetime(), // ISO 8601 date
});

type Order = z.infer<typeof OrderSchema>;

export const OrderDetail = ({ orderId }: { orderId: number }) => {
const [order, setOrder] = useState<Order | null>(null);

useEffect(() => {
const fetchOrder = async () => {
try {
const response = await fetch(`https://api.example.com/orders/${orderId}`);
const data = await response.json();
const validatedOrder = OrderSchema.parse(data);
setOrder(validatedOrder);
} catch (err) {
console.error('Invalid order:', err);
}
};

fetchOrder();
}, [orderId]);

if (!order) return <p>Loading...</p>;

return (
<div>
<h2>Order {order.id}</h2>
<p>Total: ${order.total}</p>
<h3>Items</h3>
<ul>
{order.items.map((item, idx) => (
<li key={idx}>
Product {item.productId}: {item.quantity}x ${item.price}
</li>
))}
</ul>
</div>
);
};

Composing schemas makes them reusable and easier to maintain. If the address format changes, you update AddressSchema once and it automatically applies to all schemas that use it.

Transforming Data During Validation

Zod can transform data as part of validation, like converting dates from strings:

// Transform string dates to Date objects
const EventSchema = z.object({
id: z.number(),
title: z.string(),
startTime: z.string().datetime().transform(str => new Date(str)),
endTime: z.string().datetime().transform(str => new Date(str)),
});

type Event = z.infer<typeof EventSchema>;

export const EventList = () => {
const [events, setEvents] = useState<Event[]>([]);

useEffect(() => {
const fetchEvents = async () => {
try {
const response = await fetch('https://api.example.com/events');
const data = await response.json();
const validatedEvents = z.array(EventSchema).parse(data);
// Now validatedEvents contains Date objects, not strings
setEvents(validatedEvents);
} catch (err) {
console.error('Invalid events:', err);
}
};

fetchEvents();
}, []);

return (
<ul>
{events.map(event => (
<li key={event.id}>
{event.title} from {event.startTime.toLocaleTimeString()} to {event.endTime.toLocaleTimeString()}
</li>
))}
</ul>
);
};

The .transform() method converts the validated string into a Date object. The inferred type reflects this transformation, so event.startTime is a Date, not a string.

Key Takeaways

  • Zod validates JSON at runtime and catches shape mismatches before they cause bugs.
  • Use z.infer<typeof schema> to extract TypeScript types from Zod schemas, keeping types and validation in sync.
  • Use .parse() for strict validation that throws on errors, or .safeParse() for graceful handling.
  • Compose schemas by combining smaller validators, making them reusable and maintainable.
  • Use .transform() to convert validated data (like string dates to Date objects).

Frequently Asked Questions

What if an API response has optional fields I don't know about in advance?

Use z.object({ ... }).passthrough() to allow unknown fields, or .strip() to remove them. By default, Zod strips unknown properties.

How do I validate field values beyond type checking?

Chain validation methods: z.string().email() validates an email, z.number().positive() ensures numbers are greater than 0, z.string().min(8) enforces minimum length.

Can I share schemas across multiple files?

Yes, define schemas in a separate schemas.ts file and import them wherever you need to validate data.

What if the API returns a union type (could be user or admin)?

Use z.discriminatedUnion() or z.union(): z.union([UserSchema, AdminSchema]). This validates against multiple possible shapes.

Does Zod add significant bundle size?

Zod is about 16 KB minified. For applications where bundle size is critical, consider alternatives like typia or inline validation, but Zod is a good default choice for most projects.

Further Reading