How to Build a Type-Safe API Client with TypeScript
How to Build a Type-Safe API Client with TypeScript
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.
Level 1: Basic Typed Client
// 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
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
Generate the entire client from an OpenAPI spec:
# 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
Share types between your API and client (for APIs you own):
// 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
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 |
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.