Building Type-Safe APIs with Schema Validation
In modern web development, ensuring type safety across the entire application stack has become increasingly important. This article explores how to create robust, type-safe API interactions using TypeScript and schema validation libraries. We'll dive into patterns for creating self-documenting, type-safe APIs that provide confidence at compile time rather than discovering issues at runtime.
The Problem: Type Safety Across Boundaries
When working with APIs, we often face a fundamental challenge: how do we ensure that the data crossing system boundaries conforms to our expectations? TypeScript provides excellent type safety within our application, but those guarantees disappear when we interact with external systems or APIs.
Consider this common scenario:
// A typical API call
const fetchUserData = async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data; // What's the type of data? We don't know!
};
The data
returned from this function could be anything. TypeScript has no way to verify that the server response matches our expected shape. We could manually type it:
interface User {
id: string;
name: string;
email: string;
}
const fetchUserData = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data as User; // Unsafe type assertion!
};
But this approach has a critical flaw: we're simply telling TypeScript to trust us that the data will conform to our User
interface. There's no runtime validation, and if the API changes, our code might break in unpredictable ways.
The Solution: Schema Validation
Schema validation libraries like Zod or Valibot solve this problem by providing both runtime validation and compile-time type inference. Let's see how this works:
import { z } from 'zod';
// Define a schema
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// Infer a type from the schema
type User = z.infer<typeof UserSchema>;
const fetchUserData = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Validate the data at runtime
const validatedData = UserSchema.parse(data);
return validatedData; // Now we know this is a valid User
};
Now we have both runtime validation and proper TypeScript types. If the API returns unexpected data, we'll get a helpful validation error rather than mysterious bugs later on.
Building a Type-Safe API Client
Let's take this concept further and build a complete type-safe API client system using design patterns that promote reusability and type safety.
1. The Schema Repository Pattern
First, we'll define all our API schemas in a central location:
// schemas.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export const CreateUserInputSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
});
export const UpdateUserInputSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
});
// Infer types from schemas
export type User = z.infer<typeof UserSchema>;
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserInputSchema>;
This approach centralizes our data definitions and makes it easy to update schemas as our API evolves.
2. The API Response Wrapper Pattern
Next, let's define a standard response wrapper to handle common API response patterns:
// api-types.ts
export interface ApiSuccessResponse<T> {
status: 'success';
data: T;
}
export interface ApiErrorResponse {
status: 'error';
message: string;
code?: string;
}
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
// Type guard to check if the response is successful
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
return response.status === 'success';
}
3. The Type-Safe API Client Pattern
Now, let's create a reusable function to make type-safe API calls:
// api-client.ts
import { z } from 'zod';
import { ApiResponse } from './api-types';
export async function apiRequest<TResponseSchema extends z.ZodType, TInput = void>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
responseSchema: TResponseSchema,
input?: TInput,
): Promise<ApiResponse<z.infer<TResponseSchema>>> {
try {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: input ? JSON.stringify(input) : undefined,
});
const responseData = await response.json();
if (!response.ok) {
return {
status: 'error',
message: responseData.message || 'An unknown error occurred',
code: responseData.code,
};
}
// Validate the response against the schema
const validatedData = responseSchema.parse(responseData);
return {
status: 'success',
data: validatedData,
};
} catch (error) {
return {
status: 'error',
message: error instanceof Error ? error.message : 'An unknown error occurred',
};
}
}
4. The Endpoint Factory Pattern
Finally, let's create a factory function that generates type-safe API endpoints:
// api-endpoints.ts
import { z } from 'zod';
import { apiRequest } from './api-client';
import { ApiResponse } from './api-types';
interface EndpointOptions<TResponseSchema extends z.ZodType, TInput> {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
responseSchema: TResponseSchema;
inputSchema?: z.ZodType<TInput>;
}
export function createEndpoint<TResponseSchema extends z.ZodType, TInput = void>(
options: EndpointOptions<TResponseSchema, TInput>,
) {
return async (input?: TInput): Promise<ApiResponse<z.infer<TResponseSchema>>> => {
// Validate input if schema is provided
if (options.inputSchema && input) {
options.inputSchema.parse(input);
}
return apiRequest(options.url, options.method, options.responseSchema, input);
};
}
// Type inference helper
export type InferEndpointResponse<T> = T extends (...args: any[]) => Promise<ApiResponse<infer R>>
? R
: never;
export type InferEndpointInput<T> = T extends (input: infer I) => any ? I : void;
5. Putting It All Together
Now we can create type-safe API endpoints:
// users-api.ts
import { z } from 'zod';
import { createEndpoint, InferEndpointResponse, InferEndpointInput } from './api-endpoints';
import { UserSchema, CreateUserInputSchema, UpdateUserInputSchema } from './schemas';
// Define endpoints
export const getUserEndpoint = createEndpoint({
url: '/api/users',
method: 'GET',
responseSchema: z.array(UserSchema),
});
export const createUserEndpoint = createEndpoint({
url: '/api/users',
method: 'POST',
responseSchema: UserSchema,
inputSchema: CreateUserInputSchema,
});
export const updateUserEndpoint = createEndpoint({
url: '/api/users',
method: 'PUT',
responseSchema: UserSchema,
inputSchema: UpdateUserInputSchema,
});
// Infer types from endpoints
export type GetUsersResponse = InferEndpointResponse<typeof getUserEndpoint>;
export type CreateUserParams = InferEndpointInput<typeof createUserEndpoint>;
export type UpdateUserParams = InferEndpointInput<typeof updateUserEndpoint>;
And finally, we can use these endpoints with full type safety:
// Example usage
import { getUserEndpoint, createUserEndpoint, isApiSuccess } from './api';
async function fetchUsers() {
const response = await getUserEndpoint();
if (isApiSuccess(response)) {
// response.data is fully typed as User[]
const users = response.data;
console.log(users.map((user) => user.name).join(', '));
} else {
console.error('Error fetching users:', response.message);
}
}
async function createUser() {
const response = await createUserEndpoint({
name: 'John Doe',
email: 'john@example.com',
password: 'secure123',
});
if (isApiSuccess(response)) {
// response.data is fully typed as User
console.log(`Created user: ${response.data.id}`);
} else {
console.error('Error creating user:', response.message);
}
}
Benefits of This Approach
The schema validation and type-safe API patterns we've explored offer several significant benefits:
- Compile-time Safety: TypeScript will catch many errors before they reach runtime.
- Self-documenting Code: The schema definitions serve as both validation logic and documentation.
- Runtime Validation: We validate data at runtime, providing a defense against API changes or malformed data.
- Separation of Concerns: The factory pattern separates endpoint definitions from their implementation.
- DRY Code: We define the schema once and derive both types and validation from it.
- Type Inference: We can infer types from schemas and endpoints, reducing the need for duplicate type definitions.
Design Patterns Used
This approach incorporates several design patterns:
- Repository Pattern: Centralizing schemas in a single location.
- Factory Pattern: Creating endpoints with consistent behavior.
- Adapter Pattern: The API client adapts the fetch API to our type-safe interface.
- Type Guard Pattern: Using
isApiSuccess
to narrow types safely. - Decorator Pattern: Adding validation to our API calls.
Conclusion
By combining TypeScript with schema validation, we can create API interactions that are both type-safe at compile time and validated at runtime. This approach significantly reduces the likelihood of bugs caused by unexpected data shapes and provides a more robust way to handle API responses.
The patterns shown here can be adapted to work with any schema validation library and any API client. Whether you're using Axios, fetch, or another HTTP client, these patterns will help you create more reliable, self-documenting code that's easier to maintain and evolve over time.
As web applications continue to grow in complexity, investing in this type of robust type safety infrastructure pays dividends in reduced bugs, improved developer experience, and more maintainable codebases.