Building a Type-Safe API Client with Zod
Prerequisites
- TypeScript 5+
- Understanding of Zod schemas
- Fetch or Axios knowledge
- A lingering distrust of backend developers
- At least one mass-casualty
as anyincident in your past
What We’re Building
A fully type-safe API client that validates responses at runtime with Zod and provides complete TypeScript inference. No more as unknown as User casts. No more crossing your fingers and hoping the backend team didnt rename a field on Friday afternoon.
If you’ve ever watched your app silently swallow garbage data from an API and display undefined where a username should be, this one’s for you.

The Approach
- Define response schemas
- Create a typed fetch wrapper
- Add error handling
- Build resource-specific clients
- Handle pagination
Nothing revolutionary. Just solid engineering that your future self will thank you for, instead of the usual “who wrote this” followed by a git blame that points right back at you.
Step 1: Define Schemas
This is where the magic starts. You describe what your API should return, and Zod holds it accountable.
// schemas/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
});
export const UserListSchema = z.object({
data: z.array(UserSchema),
meta: z.object({
total: z.number(),
page: z.number(),
perPage: z.number(),
}),
});
export type User = z.infer<typeof UserSchema>;
export type UserList = z.infer<typeof UserListSchema>;
Notice how z.infer means you never write the type and the schema separately. Single source of truth. Write it once, infer the rest. Its the kind of thing that makes you wonder why you ever did it any other way.
Step 2: Create the Base Client
Here’s where we build the actual fetch wrapper. Two custom error classes give us proper error discrimination downstream, and the request function handles URL building, the fetch call, and validation in one neat pipeline.
// lib/api-client.ts
import { z } from 'zod';
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public body?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
export class ValidationError extends Error {
constructor(
message: string,
public errors: z.ZodError
) {
super(message);
this.name = 'ValidationError';
}
}
interface RequestOptions extends RequestInit {
params?: Record<string, string | number | boolean>;
}
export function createApiClient(baseUrl: string, defaultHeaders: HeadersInit = {}) {
async function request<T>(
path: string,
schema: z.ZodType<T>,
options: RequestOptions = {}
): Promise<T> {
const { params, ...fetchOptions } = options;
let url = `${baseUrl}${path}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.append(key, String(value));
});
url += `?${searchParams.toString()}`;
}
const response = await fetch(url, {
...fetchOptions,
headers: {
'Content-Type': 'application/json',
...defaultHeaders,
...fetchOptions.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new ApiError(
`API request failed: ${response.statusText}`,
response.status,
body
);
}
const data = await response.json();
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationError(
'API response validation failed',
result.error
);
}
return result.data;
}
return {
get: <T>(path: string, schema: z.ZodType<T>, options?: RequestOptions) =>
request(path, schema, { ...options, method: 'GET' }),
post: <T>(path: string, schema: z.ZodType<T>, body: unknown, options?: RequestOptions) =>
request(path, schema, {
...options,
method: 'POST',
body: JSON.stringify(body),
}),
put: <T>(path: string, schema: z.ZodType<T>, body: unknown, options?: RequestOptions) =>
request(path, schema, {
...options,
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(path: string, schema: z.ZodType<T>, options?: RequestOptions) =>
request(path, schema, { ...options, method: 'DELETE' }),
};
}
The key bit is safeParse. Instead of throwing on bad data (which is what .parse does), we get a discriminated union back. Success or failure, cleanly separated. If the API sends you nonsense, you find out here, not three components deep when someone tries to render user.email and gets Cannot read properties of undefined.
Step 3: Build Resource Clients
Now we layer resource-specific logic on top of the base client. Each resource gets its own module, its own schemas, and its own input validation.
// lib/resources/users.ts
import { createApiClient } from '../api-client';
import { UserSchema, UserListSchema, User } from '../../schemas/user';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
export function createUsersClient(client: ReturnType<typeof createApiClient>) {
return {
list: (params?: { page?: number; perPage?: number }) =>
client.get('/users', UserListSchema, { params }),
get: (id: string) =>
client.get(`/users/${id}`, UserSchema),
create: (input: CreateUserInput) => {
const validated = CreateUserSchema.parse(input);
return client.post('/users', UserSchema, validated);
},
update: (id: string, input: Partial<CreateUserInput>) =>
client.put(`/users/${id}`, UserSchema, input),
delete: (id: string) =>
client.delete(`/users/${id}`, z.object({ success: z.boolean() })),
};
}
Notice the create method validates input before sending it. This isnt just about protecting against bad API responses. You’re validating both directions. If someone passes garbage into your client, they find out immediately, not after a round trip to the server and a cryptic 422.
Step 4: Compose the Full Client
Time to stitch it all together. The factory function takes config, sets up auth headers, creates the base client, then hands it off to each resource client.
// lib/index.ts
import { createApiClient } from './api-client';
import { createUsersClient } from './resources/users';
import { createPostsClient } from './resources/posts';
export function createClient(config: { baseUrl: string; token?: string }) {
const headers: HeadersInit = {};
if (config.token) {
headers['Authorization'] = `Bearer ${config.token}`;
}
const client = createApiClient(config.baseUrl, headers);
return {
users: createUsersClient(client),
posts: createPostsClient(client),
};
}
export type Client = ReturnType<typeof createClient>;
And using it feels exactly like you’d hope:
const api = createClient({
baseUrl: 'https://api.example.com',
token: 'your-token',
});
const users = await api.users.list({ page: 1, perPage: 20 });
const user = await api.users.get('123');
const newUser = await api.users.create({
email: '[email protected]',
name: 'John',
});
Full autocomplete. Full type safety. If you try to access user.banana, TypeScript stops you before you even save the file.


Step 5: Error Handling
When things go wrong (and they will), you want to know exactly what went wrong. Was it a network error? A 404? Or did the API return something that doesnt match your schema?
import { ApiError, ValidationError } from './lib/api-client';
try {
const user = await api.users.get('invalid-id');
} catch (error) {
if (error instanceof ApiError) {
console.error(`API Error ${error.status}: ${error.message}`);
if (error.status === 404) {
console.error('User not found');
}
} else if (error instanceof ValidationError) {
console.error('Invalid API response:', error.errors.format());
} else {
throw error;
}
}
The instanceof checks give you proper narrowing. Inside each branch, TypeScript knows exactly which properties are available. No guessing, no casting.
Step 6: Handle Transforms
Sometimes the API gives you strings where you want proper types. Zod transforms let you coerce data as part of validation.
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string().datetime().transform((s) => new Date(s)),
settings: z.string().transform((s) => JSON.parse(s) as UserSettings),
});
Your schema says “this comes in as a string, but I want a Date.” Zod handles the conversion, and the inferred type reflects the output, not the input. Downstream code sees Date, not string. Lovely.
Step 7: Testing
Because what good is a type-safe client if you dont test it?
import { describe, it, expect, vi } from 'vitest';
import { createClient } from './lib';
describe('API Client', () => {
it('validates response schema', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
id: '123',
email: '[email protected]',
name: 'Test User',
role: 'user',
createdAt: '2025-01-01T00:00:00Z',
}),
});
const api = createClient({ baseUrl: 'https://api.test' });
const user = await api.users.get('123');
expect(user.email).toBe('[email protected]');
});
it('throws on invalid response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: '123' }),
});
const api = createClient({ baseUrl: 'https://api.test' });
await expect(api.users.get('123')).rejects.toThrow('validation failed');
});
});
Mock the fetch, assert the behaviour. The second test is the important one. When the API returns incomplete data, your client throws instead of silently propagating garbage through your app. That’s the whole point.

The Result
What you end up with:
- Full type inference from schemas, no manual type definitions
- Runtime validation of every API response
- Meaningful error types you can actually handle
- Composable resource clients that scale with your API
- Dead simple testing with mocked fetch
What I’d Do Differently
Add request interceptors for token refresh. I bolted it on later, but having a proper middleware system from the start would’ve been cleaner. A simple chain of (request) => request transforms before the fetch call would’ve saved me a messy refactor down the line.
Never trust an API response. Even your own. Especially your own. Schema validation catches breaking changes before they crash your app, and Zod makes it almost enjoyable.