Building Robust API Clients with TypeScript and Zod
Emily Parker
Product Engineer · Leapcell

Introduction
In the intricate world of web development, interacting with APIs is a daily staple. We send requests, and we receive data. Simple enough, right? Not always. The silent dangers of untyped API responses can lead to a cascade of runtime errors, unexpected behavior, and frustrating debugging sessions. Imagine receiving a user object where 'id' is a string instead of a number, or a 'data' field that's sometimes an array and sometimes null. These inconsistencies, often hidden until they manifest in production, erode trust in our applications and slow down development. This is precisely where the power of type safety becomes invaluable. By proactively defining and validating the structure of API responses, we can catch these issues early, preventing bugs and enhancing developer experience. This article will guide you through building a resilient and type-safe API request client using the formidable combination of TypeScript and Zod, transforming your API interactions from a potential minefield into a well-guarded stronghold.
Core Concepts Explained
Before we dive into the implementation, let's establish a clear understanding of the key tools and concepts we'll be leveraging:
- TypeScript: An open-source language that builds on JavaScript by adding static type definitions. It allows developers to define the shapes of objects, functions, and variables, enabling excellent tooling, early error detection, and improved code readability and maintainability. With TypeScript, you describe what your data should look like.
- Zod: A TypeScript-first schema declaration and validation library. Zod allows you to define data schemas that can be used to validate incoming data at runtime. It's renowned for its powerful inference capabilities, meaning that once you define a Zod schema, TypeScript can automatically infer the corresponding static type. This makes Zod an ideal partner for TypeScript, as it provides a robust mechanism to ensure incoming untrusted data conforms to your expected types. Think of Zod as the bouncer for your data, ensuring only "well-behaved" data enters your application.
- API Client: A module or set of functions responsible for making HTTP requests to a backend API and handling the responses. It abstracts away the details of the HTTP protocol, providing a cleaner interface for application logic to interact with external services.
Crafting a Type-Safe API Client
The fundamental principle here is to define a Zod schema for each API response structure and then use that schema to validate the data immediately after receiving it from the network. TypeScript, leveraging Zod's inference, will then provide us with compile-time type safety for this validated data.
Setting up the Project
First, let's ensure we have the necessary packages installed:
npm install axios zod typescript npm install --save-dev @types/node # if needed for your environment
Defining API Schemas with Zod
Let's imagine we're interacting with an API that manages users and posts. We'll define Zod schemas for these entities.
// src/schemas.ts import { z } from 'zod'; // Schema for a single user export const UserSchema = z.object({ id: z.number().int().positive(), name: z.string().min(1, 'Name cannot be empty'), email: z.string().email('Invalid email address'), age: z.number().int().positive().optional(), // Optional field }); // Infer the TypeScript type from the Zod schema export type User = z.infer<typeof UserSchema>; // Schema for a single post export const PostSchema = z.object({ id: z.number().int().positive(), userId: z.number().int().positive(), title: z.string().min(1, 'Title cannot be empty'), body: z.string().min(1, 'Body cannot be empty'), }); // Infer the TypeScript type from the Zod schema export type Post = z.infer<typeof PostSchema>; // Schema for an API response that might contain a list of users export const UsersResponseSchema = z.array(UserSchema); export type UsersResponse = z.infer<typeof UsersResponseSchema>; // Schema for an API response that might contain a list of posts export const PostsResponseSchema = z.array(PostSchema); export type PostsResponse = z.infer<typeof PostsResponseSchema>; // Common error response schema (optional, but good practice) export const ErrorResponseSchema = z.object({ message: z.string(), code: z.number().optional(), }); export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
Here, we've defined precise Zod schemas for User
and Post
objects, including validation rules like min(1)
for strings, positive()
for numbers, and email()
for email formats. Crucially, z.infer<typeof Schema>
allows TypeScript to automatically create an interface or type alias that perfectly matches our Zod schema.
Building the API Client
Now, let's create a generic API client that leverages these schemas for request handling. We'll use axios
for HTTP requests.
// src/apiClient.ts import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios'; import { ZodSchema, z } from 'zod'; import { User, UsersResponse, Post, PostsResponse, ErrorResponseSchema } from './schemas'; // Create a configured Axios instance const api: AxiosInstance = axios.create({ baseURL: 'https://jsonplaceholder.typicode.com', // Example API timeout: 10000, headers: { 'Content-Type': 'application/json', }, }); // Generic function to make a request and validate the response async function fetchData<T>( url: string, schema: ZodSchema<T> ): Promise<T> { try { const response: AxiosResponse<unknown> = await api.get(url); // Validate the response data against the provided Zod schema const validatedData = schema.parse(response.data); return validatedData; } catch (error) { if (error instanceof z.ZodError) { // Data validation error console.error('API response validation failed:', error.errors); throw new Error(`Data validation error: ${error.errors.map(e => e.message).join(', ')}`); } else if (axios.isAxiosError(error)) { // Axios HTTP error const axiosError = error as AxiosError; console.error('API request failed:', axiosError.message); if (axiosError.response?.data) { try { // Attempt to parse the error response data if it exists const errorResponse = ErrorResponseSchema.parse(axiosError.response.data); console.error('API error details:', errorResponse.message); throw new Error(`API Error: ${errorResponse.message}`); } catch (parseError) { console.error('Could not parse API error response:', parseError); throw new Error(`API Error: ${axiosError.response.status} - ${axiosError.message}`); } } throw new Error(`Network Error: ${axiosError.message}`); } else { // Unknown error console.error('An unexpected error occurred:', error); throw new Error('An unknown error occurred'); } } } // Specific API client functions export const usersApiClient = { getUsers: (): Promise<UsersResponse> => fetchData<UsersResponse>('/users', UsersResponseSchema), getUserById: (id: number): Promise<User> => fetchData<User>(`/users/${id}`, UserSchema), }; export const postsApiClient = { getPosts: (): Promise<PostsResponse> => fetchData<PostsResponse>('/posts', PostsResponseSchema), getPostById: (id: number): Promise<Post> => fetchData<Post>(`/posts/${id}`, PostSchema), };
In fetchData
, we:
- Make the HTTP request using
axios
. Note thatAxiosResponse<unknown>
is used initially because we don't trust the incoming data yet. - Crucially, we call
schema.parse(response.data)
. This is where Zod meticulously validates the received data against our predefined schema. If the data doesn't conform, Zod throws aZodError
. - The
catch
block differentiates between Zod validation errors, Axios HTTP errors, and other unexpected issues, providing specific error messages. - If validation succeeds, the
validatedData
is guaranteed to be of typeT
(inferred from theschema
), thanks toz.infer
.
Consuming the Type-Safe Client
Now, consuming this client feels incredibly safe and intuitive:
// src/app.ts import { usersApiClient, postsApiClient } from './apiClient'; import { User, Post } from './schemas'; // Only need types here async function main() { console.log('Fetching users...'); try { const users: User[] = await usersApiClient.getUsers(); console.log(`Fetched ${users.length} users.`); // TypeScript knows `users` is an array of `User` objects. // autocomplete will work, and you'll get compile-time errors if you misuse properties. const firstUser = users[0]; if (firstUser) { console.log(`First user: ID=${firstUser.id}, Name=${firstUser.name}, Email=${firstUser.email}`); // Try to access a non-existent property - TypeScript will alert you! // console.log(firstUser.address.city); // ERROR: Property 'address' does not exist on type 'User' } console.log('\nFetching a specific post (ID 1)...'); const post: Post = await postsApiClient.getPostById(1); console.log(`Fetched post: Title="${post.title}" by User ID ${post.userId}`); // Example of invalid data (simulated scenario) // If the API unexpectedly sends bad data, Zod will catch it! // For example, if '/users' returned: [{ id: '1', name: 'John Doe' }] // fetchData would throw a ZodError because 'id' is expected to be a number. } catch (error: any) { console.error('An error occurred during data fetching:', error.message); } } main();
In this consumption example, notice how users: User[]
and post: Post
are explicitly typed. This isn't just for documentation; it's enforced by TypeScript at compile-time because our fetchData
function is guaranteed to return T
after Zod validation. If an API response deviates from the schema, Zod will throw an error before the malformed data ever reaches your application logic.
Application Scenarios
This pattern is incredibly valuable in several scenarios:
- Public APIs: When consuming third-party APIs where you have less control over the data format, Zod provides a crucial layer of defense against unexpected changes or malformed responses.
- Microservices: In a microservice architecture, different teams might own different services. Zod schemas act as a contract, ensuring that communication between services adheres to agreed-upon data structures.
- Frontend-Backend Separation: When frontend and backend teams work independently, Zod schemas can be shared to ensure type consistency, reducing misunderstandings and integration issues.
- Data Transformation: Zod can also be used for data transformation by adding
.transform()
to schemas, allowing you to reshape data as it's validated.
Conclusion
By integrating TypeScript for static type checking and Zod for runtime data validation, we empower our API clients with an unparalleled level of reliability. This synergistic approach ensures that the data entering our applications is not only structurally sound at compile-time but also truly conforms to our expectations at runtime, preventing a wide array of common data-related bugs. Embrace TypeScript and Zod to build API clients that are robust, maintainable, and a joy to work with.