How to Build a Type-Safe API Client with 2026
A type-safe API client catches errors at compile time instead of runtime. Instead of discovering that user.name is now user.full_name when your app crashes in production, TypeScript tells you at build time. Here's how to build one — and how to choose the right level of type safety for your situation.
There are two fundamentally different categories of API client bugs. The first category — wrong field name, wrong argument type, calling a method that doesn't exist — is caught at compile time with any typed approach. TypeScript's static analysis handles this well once you have type definitions in place. The second category — the API returns a field shape you didn't expect, a field is nullable when you assumed it wasn't, an enum gains new values — is only caught at runtime, and only if you're validating. This distinction matters because many teams adopt TypeScript and think they're safe, when in reality they've only addressed the first category.
The progression from "TypeScript trusts the API" to "TypeScript verifies the API" maps directly to the four levels covered here. Level 1 (manual types) gives you compile-time safety for the first category. Level 2 (Zod validation) adds runtime safety for the second. Level 3 (OpenAPI codegen) automates Level 1 for APIs that have a spec. Level 4 (tRPC) eliminates the boundary between frontend and backend entirely. Each level is appropriate in different contexts — the goal isn't to always use the most sophisticated approach, but to match the level to the API and use case.
TL;DR
Start at Level 2 (Zod validation) for any third-party API you don't control. Field renames and type changes from external APIs are silent at Level 1 and loud at Level 2 — a validation error in development is far cheaper than a production crash. Use Level 3 (OpenAPI codegen) when the API provides an OpenAPI spec — hand-writing types for an OpenAPI-documented API is always stale compared to generated types. Use Level 4 (tRPC) for internal APIs you own and control, especially in monorepos. Level 1 alone is better than nothing but gives false confidence for third-party APIs.
Level 1: Basic Typed Client
What basic typing buys you is substantial and worth having: IDE autocompletion that makes exploring API responses fast, refactoring safety so renaming a field in your types propagates correctly, and inline documentation that means developers don't need to context-switch to API docs for common operations. For APIs you own and control — or for prototyping where speed matters more than rigor — Level 1 is often the pragmatic choice.
What Level 1 doesn't buy you is any guarantee about what the API actually returns. If the API changes its response format and you haven't updated your types, TypeScript still compiles happily. Your code will run, but user.name will silently be undefined while TypeScript insists it's a string. This false confidence is the reason Level 1 alone is dangerous for third-party APIs in production.
// Define your API types
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
interface CreateUserInput {
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
meta?: { total: number; page: number };
}
// Typed API client
class ApiClient {
constructor(private baseUrl: string, private apiKey: string) {}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return response.json();
}
// Each method is fully typed
async getUsers(): Promise<ApiResponse<User[]>> {
return this.request('GET', '/users');
}
async getUser(id: string): Promise<User> {
return this.request('GET', `/users/${id}`);
}
async createUser(input: CreateUserInput): Promise<User> {
return this.request('POST', '/users', input);
}
async updateUser(id: string, input: Partial<CreateUserInput>): Promise<User> {
return this.request('PUT', `/users/${id}`, input);
}
async deleteUser(id: string): Promise<void> {
return this.request('DELETE', `/users/${id}`);
}
}
class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API Error ${status}: ${body}`);
}
}
// Usage — everything is typed
const api = new ApiClient('https://api.example.com/v1', process.env.API_KEY!);
const users = await api.getUsers();
// users.data[0].name → string ✅
// users.data[0].phone → TypeScript error ❌
const newUser = await api.createUser({
name: 'Jane',
email: 'jane@example.com',
// phone: '555-0123', → TypeScript error ❌ (not in CreateUserInput)
});
Problem: TypeScript trusts you. If the API actually returns { full_name: "Jane" } instead of { name: "Jane" }, TypeScript won't catch it. You need runtime validation.
Level 2: Runtime Validation with Zod
Zod is a schema declaration and validation library that doubles as TypeScript's type system source of truth. The key insight is "define once, use everywhere": you write a schema once, and Zod infers the TypeScript type from it automatically. There's no manual synchronization between your validation logic and your type definitions — they're the same thing. When you update the schema (adding a new required field, making a field optional, changing an enum), the TypeScript type updates automatically.
For third-party APIs, this means your types are always what you've validated, not what you assume. When a field gets renamed in the API response, Zod throws a clear validation error with the path and expected/received values — you know exactly what changed and where. Without Zod, that same field rename causes a silent undefined that may not surface until it causes a downstream error in your business logic, far from the API call that introduced it. Level 2 is the minimum acceptable approach for any third-party API in a production application.
import { z } from 'zod';
// Define schemas that validate at runtime AND generate types
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
role: z.enum(['user', 'admin', 'editor']),
});
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
data: dataSchema,
meta: z.object({
total: z.number(),
page: z.number(),
}).optional(),
});
// Types are inferred from schemas — single source of truth
type User = z.infer<typeof UserSchema>;
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Validated API client
class ValidatedApiClient {
constructor(private baseUrl: string, private apiKey: string) {}
private async request<T>(
method: string,
path: string,
schema: z.ZodType<T>,
body?: unknown
): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
const json = await response.json();
// Validate response matches expected schema
const result = schema.safeParse(json);
if (!result.success) {
console.error('API response validation failed:', {
path,
errors: result.error.issues,
received: json,
});
throw new ApiValidationError(path, result.error);
}
return result.data;
}
async getUsers() {
return this.request(
'GET',
'/users',
ApiResponseSchema(z.array(UserSchema))
);
}
async getUser(id: string) {
return this.request('GET', `/users/${id}`, UserSchema);
}
async createUser(input: CreateUserInput) {
// Validate input before sending
CreateUserSchema.parse(input);
return this.request('POST', '/users', UserSchema, input);
}
}
class ApiValidationError extends Error {
constructor(public path: string, public zodError: z.ZodError) {
super(`API response validation failed for ${path}`);
}
}
Now you get: Compile-time type checking AND runtime validation. If the API changes its response format, you get a clear error immediately.
Level 3: OpenAPI Codegen
For APIs with an OpenAPI spec, hand-writing types is always going to drift from reality. The spec is the authoritative source of truth — any hand-written types are a copy, and copies go stale. The right answer is to generate types directly from the spec as part of your CI pipeline: every time you run npm install or on a scheduled basis, regenerate your types from the live spec. If the API adds a new required field or changes a response format, your next generation run surfaces the breaking change in your types before it surfaces as a runtime error.
The openapi-typescript + openapi-fetch stack is the current best practice for this pattern. openapi-typescript generates TypeScript types from any OpenAPI 3.x spec. openapi-fetch provides a fully-typed fetch client that uses those generated types — path parameters, query parameters, request bodies, and responses are all typed based on the spec. The result is Level 2-quality safety for path and parameter correctness (TypeScript error if you call a nonexistent endpoint or pass the wrong parameter names) without any manual type maintenance. For a broader look at TypeScript SDK design patterns beyond codegen, see our TypeScript API client SDK guide.
# Install
npm install -D openapi-typescript openapi-fetch
# Generate types from OpenAPI spec
npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts
// Generated types from OpenAPI spec
import createClient from 'openapi-fetch';
import type { paths } from './schema';
const api = createClient<paths>({
baseUrl: 'https://api.example.com/v1',
headers: { Authorization: `Bearer ${API_KEY}` },
});
// Fully typed — paths, methods, parameters, responses all inferred
const { data, error } = await api.GET('/users/{id}', {
params: { path: { id: '123' } },
});
// data is typed as paths['/users/{id}']['get']['responses']['200']['content']['application/json']
const { data: newUser } = await api.POST('/users', {
body: { name: 'Jane', email: 'jane@example.com' },
});
// TypeScript errors:
// api.GET('/nonexistent') → path doesn't exist
// api.POST('/users', { body: { invalid: true } }) → wrong body shape
// data.nonExistentField → property doesn't exist
Level 4: End-to-End Type Safety
tRPC eliminates the API boundary for TypeScript applications. Instead of defining an API contract and implementing clients on both sides, you define procedures on the server and TypeScript infers the types on the client — there's no schema, no codegen step, no spec to maintain. The client and server share types through TypeScript's type system directly.
The trade-off is coupling. tRPC works beautifully in a monorepo or a full-stack TypeScript application where the frontend and backend are deployed together. It creates a tight dependency: the client must be TypeScript, the server must expose a tRPC router, and both must live in the same repository or package ecosystem. For internal tools, admin dashboards, and full-stack SaaS products, this coupling is often an acceptable or even desirable constraint. For public APIs, mobile clients, or teams where different groups own the frontend and backend independently, the coupling is problematic — which is why Level 3 remains important alongside Level 4. The choice between these approaches relates closely to the broader REST vs GraphQL vs gRPC vs tRPC decision.
// shared/types.ts — shared between server and client
export interface User {
id: string;
name: string;
email: string;
}
export interface CreateUserInput {
name: string;
email: string;
}
// Or with tRPC — full stack type safety, zero codegen
// server/router.ts
import { z } from 'zod';
import { router, publicProcedure } from './trpc';
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findById(input.id);
}),
createUser: publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.users.create(input);
}),
});
export type AppRouter = typeof appRouter;
// client/api.ts
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({ /* config */ });
// Types flow from server to client automatically
const user = await trpc.getUser.query({ id: '123' });
// user.name → string ✅
// user.nonExistent → TypeScript error ❌
const newUser = await trpc.createUser.mutate({
name: 'Jane',
email: 'jane@example.com',
});
// Argument validation happens on both client and server
Utility Patterns
Some patterns are useful regardless of which level you're operating at. These aren't tied to any specific type safety approach — they're general-purpose tools for API client robustness. A generic paginated fetcher handles the cursor/offset iteration pattern that most list endpoints implement. Type-safe error handling using discriminated unions forces callers to explicitly handle the error case rather than letting exceptions propagate implicitly. These patterns work equally well whether your types come from hand-written interfaces, Zod schemas, or OpenAPI codegen.
For production API clients, pairing these utility patterns with structured error handling — retry logic, circuit breakers, error classification — creates an integration that handles real-world API behavior reliably. Our API error handling patterns guide covers the production error handling layer in detail.
Generic Paginated Fetcher
async function fetchAllPages<T>(
fetcher: (cursor?: string) => Promise<{
data: T[];
nextCursor?: string;
hasMore: boolean;
}>
): Promise<T[]> {
const allResults: T[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
const page = await fetcher(cursor);
allResults.push(...page.data);
cursor = page.nextCursor;
hasMore = page.hasMore;
}
return allResults;
}
// Type-safe usage
const allUsers = await fetchAllPages<User>(async (cursor) => {
const response = await api.GET('/users', {
params: { query: { cursor, limit: 100 } },
});
return response.data!;
});
// allUsers is User[] — fully typed
Type-Safe Error Handling
// Define error types
type ApiErrorCode =
| 'NOT_FOUND'
| 'UNAUTHORIZED'
| 'VALIDATION_ERROR'
| 'RATE_LIMITED'
| 'SERVER_ERROR';
interface TypedApiError {
code: ApiErrorCode;
message: string;
status: number;
details?: Record<string, string[]>;
}
// Result type — forces handling both success and error
type Result<T, E = TypedApiError> =
| { success: true; data: T }
| { success: false; error: E };
async function safeApiCall<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (error) {
if (error instanceof ApiError) {
return {
success: false,
error: {
code: mapStatusToCode(error.status),
message: error.body,
status: error.status,
},
};
}
return {
success: false,
error: { code: 'SERVER_ERROR', message: 'Unknown error', status: 500 },
};
}
}
// Usage — TypeScript forces you to handle errors
const result = await safeApiCall(() => api.getUser('123'));
if (result.success) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error.code); // TypeScript knows error exists
}
Type Safety Comparison
| Approach | Compile-Time Safety | Runtime Safety | Effort | Best For |
|---|---|---|---|---|
| Manual types | ✅ | ❌ | Low | Quick prototyping |
| Zod validation | ✅ | ✅ | Medium | Third-party APIs |
| OpenAPI codegen | ✅ | ❌ | Low (automated) | APIs with OpenAPI spec |
| OpenAPI + Zod | ✅ | ✅ | Medium | Maximum safety |
| tRPC | ✅ | ✅ | Low | APIs you own |
The practical recommendation: use the highest level that the API supports without significant overhead. If the API has an OpenAPI spec, use Level 3 — there's no meaningful cost to codegen over hand-writing types, and the accuracy improvement is large. If you're consuming a third-party API without a spec, use Level 2 — Zod's runtime guarantees are worth the 30 minutes of setup. If you're building internal APIs in a TypeScript monorepo, use Level 4. Reserve Level 1 for prototypes, internal scripts, and situations where you genuinely accept the trade-off of runtime risk for development speed.
The worst outcome is implementing Level 1, believing it's sufficient, and discovering 6 months later that your types drifted from reality while TypeScript silently compiled. Level 1 without Zod is better than nothing, but it creates the illusion of safety without the substance. Be honest with your team about which level you're at and what risks remain.
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
Using any in API calls | No type safety | Use generics and explicit types |
| Trusting API responses without validation | Runtime crashes | Add Zod validation |
| Hand-writing types for OpenAPI-documented APIs | Types drift from reality | Use codegen from OpenAPI spec |
| Not validating inputs before sending | Wasted API calls | Validate with Zod before fetch |
Casting with as instead of validating | Hides type mismatches | Use type guards or Zod parse |
| Not generating types in CI | Types go stale | Run codegen in CI pipeline |
Find APIs with TypeScript SDKs and OpenAPI specs on APIScout — SDK quality ratings, type safety support, and codegen compatibility.