Building a Type-Safe API Client in TypeScript - A Complete Guide
Learn how to build a fully type-safe API client in TypeScript with automatic request/response typing, error handling, and runtime validation using modern patterns.
Why Type Safety Matters for API Clients
If you've worked on a TypeScript project that communicates with external APIs, you've probably experienced this frustration: TypeScript's excellent type safety ends the moment you make an HTTP request.
// TypeScript has no idea what this returns
const data = await fetch('/api/users/123').then(r => r.json())
// data is typed as 'any' - goodbye type safety!
This tutorial will show you how to build a fully type-safe API client that:
- ✅ Provides autocomplete for all API endpoints
- ✅ Validates request payloads at compile time
- ✅ Infers response types automatically
- ✅ Handles errors with discriminated unions
- ✅ Validates responses at runtime (optional but recommended)
By the end, you'll have a reusable pattern that makes API calls as type-safe as any other TypeScript code.
What We're Building
We'll create an API client for a hypothetical blog platform with these endpoints:
GET /posts- List all postsGET /posts/:id- Get single postPOST /posts- Create new postPUT /posts/:id- Update postDELETE /posts/:id- Delete post
Our final API will look like this:
// Perfect autocomplete and type inference
const posts = await api.get('/posts')
// posts is typed as Post[]
const post = await api.post('/posts', {
title: 'Hello World',
content: 'My first post'
})
// post is typed as Post
// Compile error if payload is invalid
const invalid = await api.post('/posts', {
title: 123 // Error: Type 'number' is not assignable to type 'string'
})
Let's build it step by step.
Step 1: Define Your API Schema
First, we need to define the shape of our API. We'll use TypeScript's type system to create a schema that describes every endpoint:
// types/api.ts
// Domain models
export interface Post {
id: number
title: string
content: string
author: string
createdAt: string
updatedAt: string
published: boolean
}
export interface CreatePostRequest {
title: string
content: string
author: string
published?: boolean
}
export interface UpdatePostRequest {
title?: string
content?: string
published?: boolean
}
// API endpoint definitions
export interface ApiEndpoints {
'GET /posts': {
request: void
response: Post[]
}
'GET /posts/:id': {
request: void
response: Post
}
'POST /posts': {
request: CreatePostRequest
response: Post
}
'PUT /posts/:id': {
request: UpdatePostRequest
response: Post
}
'DELETE /posts/:id': {
request: void
response: { success: true }
}
}
This schema is the foundation of our type safety. Every endpoint's request and response types are explicitly defined.
Step 2: Create Type Utilities
Now we need TypeScript utilities to extract information from our schema:
// types/api.ts (continued)
// Extract HTTP method from endpoint string
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ExtractMethod<T extends string> =
T extends ${infer M extends HttpMethod} ${string} ? M : never
// Extract path from endpoint string
type ExtractPath<T extends string> =
T extends ${HttpMethod} ${infer P} ? P : never
// Get request type for an endpoint
type RequestType<T extends keyof ApiEndpoints> = ApiEndpoints[T]['request']
// Get response type for an endpoint
type ResponseType<T extends keyof ApiEndpoints> = ApiEndpoints[T]['response']
// Type for path parameters
type PathParams<T extends string> =
T extends ${string}:${infer Param}/${infer Rest}
? { [K in Param | keyof PathParams<Rest>]: string | number }
: T extends ${string}:${infer Param}
? { [K in Param]: string | number }
: Record<never, never>
These utility types might look complex, but they're doing simple pattern matching to extract information from our endpoint strings.
Step 3: Build the API Client Core
Now for the fun part—building the client itself:
// lib/api-client.ts
import type { ApiEndpoints, RequestType, ResponseType, PathParams } from '@/types/api'
// Custom error type
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public endpoint: string,
public details?: unknown
) {
super(message)
this.name = 'ApiError'
}
}
// Result type for error handling
export type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError }
export class ApiClient {
constructor(private baseUrl: string = '/api') {}
// Helper to build URLs with path parameters
private buildUrl<T extends string>(
path: T,
params?: PathParams<T>
): string {
let url = path
if (params) {
Object.entries(params).forEach(([key, value]) => {
url = url.replace(`:${key}`, String(value))
})
}
return `${this.baseUrl}${url}`
}
// Generic request method
private async request<T>(
method: string,
url: string,
body?: unknown
): Promise<T> {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
}
if (body !== undefined) {
options.body = JSON.stringify(body)
}
const response = await fetch(url, options)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new ApiError(
errorData.message || `HTTP ${response.status}`,
response.status,
url,
errorData
)
}
return response.json()
}
// Type-safe GET method
async get<K extends keyof ApiEndpoints>(
endpoint: K extends GET ${string} ? K : never,
params?: PathParams<K extends GET ${infer P} ? P : never>
): Promise<ResponseType<K>> {
const path = endpoint.replace('GET ', '')
const url = this.buildUrl(path, params as any)
return this.request('GET', url)
}
// Type-safe POST method
async post<K extends keyof ApiEndpoints>(
endpoint: K extends POST ${string} ? K : never,
body: RequestType<K>,
params?: PathParams<K extends POST ${infer P} ? P : never>
): Promise<ResponseType<K>> {
const path = endpoint.replace('POST ', '')
const url = this.buildUrl(path, params as any)
return this.request('POST', url, body)
}
// Type-safe PUT method
async put<K extends keyof ApiEndpoints>(
endpoint: K extends PUT ${string} ? K : never,
body: RequestType<K>,
params?: PathParams<K extends PUT ${infer P} ? P : never>
): Promise<ResponseType<K>> {
const path = endpoint.replace('PUT ', '')
const url = this.buildUrl(path, params as any)
return this.request('PUT', url, body)
}
// Type-safe DELETE method
async delete<K extends keyof ApiEndpoints>(
endpoint: K extends DELETE ${string} ? K : never,
params?: PathParams<K extends DELETE ${infer P} ? P : never>
): Promise<ResponseType<K>> {
const path = endpoint.replace('DELETE ', '')
const url = this.buildUrl(path, params as any)
return this.request('DELETE', url)
}
}
// Export singleton instance
export const api = new ApiClient()
Step 4: Add Runtime Validation (Optional)
TypeScript types are erased at runtime. To ensure API responses match our types, we can add runtime validation using Zod:
npm install zod
// types/api.ts (add runtime schemas)
import { z } from 'zod'
// Runtime schemas mirror our types
export const PostSchema = z.object({
id: z.number(),
title: z.string(),
content: z.string(),
author: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
published: z.boolean(),
})
export const CreatePostRequestSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
author: z.string().min(1),
published: z.boolean().optional(),
})
// Map endpoints to their schemas
export const ResponseSchemas = {
'GET /posts': z.array(PostSchema),
'GET /posts/:id': PostSchema,
'POST /posts': PostSchema,
'PUT /posts/:id': PostSchema,
'DELETE /posts/:id': z.object({ success: z.literal(true) }),
} as const
Update the API client to use schemas:
// lib/api-client.ts (modify request method)
import { ResponseSchemas } from '@/types/api'
private async request<T, K extends keyof typeof ResponseSchemas>(
method: string,
url: string,
endpoint: K,
body?: unknown
): Promise<T> {
// ... existing fetch logic ...
const data = await response.json()
// Runtime validation
const schema = ResponseSchemas[endpoint]
if (schema) {
const result = schema.safeParse(data)
if (!result.success) {
throw new ApiError(
'Invalid response from server',
500,
url,
result.error.issues
)
}
return result.data as T
}
return data
}
Step 5: Use the API Client
Now you can use your type-safe API client throughout your application:
// In your React component, Vue component, etc.
import { api } from '@/lib/api-client'
// Example: Fetching all posts
async function loadPosts() {
try {
const posts = await api.get('GET /posts')
// posts is automatically typed as Post[]
console.log(posts[0].title) // ✅ Autocomplete works!
} catch (error) {
if (error instanceof ApiError) {
console.error(API Error ${error.status}: ${error.message})
}
}
}
// Example: Fetching single post
async function loadPost(id: number) {
const post = await api.get('GET /posts/:id', { id })
// post is typed as Post
return post
}
// Example: Creating a post
async function createPost() {
const newPost = await api.post('POST /posts', {
title: 'My New Post',
content: 'This is the content',
author: 'John Doe',
published: true,
})
// newPost is typed as Post
return newPost
}
// Example: Updating a post
async function updatePost(id: number) {
const updated = await api.put('PUT /posts/:id',
{ title: 'Updated Title' },
{ id }
)
return updated
}
// Example: Deleting a post
async function deletePost(id: number) {
const result = await api.delete('DELETE /posts/:id', { id })
// result is typed as { success: true }
return result.success
}
Step 6: Improved Error Handling
Let's add a wrapper for cleaner error handling:
// lib/api-client.ts (add safe method)
export class ApiClient {
// ... existing methods ...
// Returns ApiResult instead of throwing
async safe<K extends keyof ApiEndpoints>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: K,
body?: RequestType<K>,
params?: any
): Promise<ApiResult<ResponseType<K>>> {
try {
let data: ResponseType<K>
switch (method) {
case 'GET':
data = await this.get(endpoint as any, params)
break
case 'POST':
data = await this.post(endpoint as any, body as any, params)
break
case 'PUT':
data = await this.put(endpoint as any, body as any, params)
break
case 'DELETE':
data = await this.delete(endpoint as any, params)
break
}
return { success: true, data }
} catch (error) {
if (error instanceof ApiError) {
return { success: false, error }
}
return {
success: false,
error: new ApiError(
error instanceof Error ? error.message : 'Unknown error',
500,
String(endpoint)
),
}
}
}
}
Usage with safe API:
const result = await api.safe('GET', 'GET /posts')
if (result.success) {
console.log(result.data) // Post[]
} else {
console.error(result.error.message)
}
Advanced: React Hook Integration
For React applications, create a custom hook:
// hooks/useApi.ts
import { useCallback, useState } from 'react'
import { api, ApiError } from '@/lib/api-client'
import type { ApiEndpoints, ResponseType } from '@/types/api'
export function useApi<K extends keyof ApiEndpoints>(
endpoint: K,
options: {
immediate?: boolean
onSuccess?: (data: ResponseType<K>) => void
onError?: (error: ApiError) => void
} = {}
) {
const [data, setData] = useState<ResponseType<K> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const execute = useCallback(async (params?: any, body?: any) => {
setLoading(true)
setError(null)
try {
const method = endpoint.split(' ')[0] as 'GET' | 'POST' | 'PUT' | 'DELETE'
let result: ResponseType<K>
switch (method) {
case 'GET':
result = await api.get(endpoint as any, params)
break
case 'POST':
result = await api.post(endpoint as any, body, params)
break
case 'PUT':
result = await api.put(endpoint as any, body, params)
break
case 'DELETE':
result = await api.delete(endpoint as any, params)
break
default:
throw new Error(`Unsupported method: ${method}`)
}
setData(result)
options.onSuccess?.(result)
return result
} catch (err) {
const apiError = err instanceof ApiError
? err
: new ApiError('Unknown error', 500, endpoint)
setError(apiError)
options.onError?.(apiError)
throw apiError
} finally {
setLoading(false)
}
}, [endpoint])
return { data, loading, error, execute }
}
Usage in components:
function PostsList() {
const { data: posts, loading, error, execute } = useApi('GET /posts')
useEffect(() => {
execute()
}, [execute])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
if (!posts) return null
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Testing Your API Client
Here's how to test your type-safe client with Vitest:
// lib/api-client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { api, ApiError } from './api-client'
// Mock fetch globally
global.fetch = vi.fn()
describe('ApiClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should fetch posts successfully', async () => {
const mockPosts = [
{ id: 1, title: 'Test Post', content: 'Content', author: 'Author', createdAt: '2025-01-01', updatedAt: '2025-01-01', published: true }
]
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockPosts,
})
const posts = await api.get('GET /posts')
expect(posts).toEqual(mockPosts)
expect(global.fetch).toHaveBeenCalledWith(
'/api/posts',
expect.objectContaining({ method: 'GET' })
)
})
it('should throw ApiError on failed request', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ message: 'Not found' }),
})
await expect(api.get('GET /posts/:id', { id: 999 }))
.rejects.toThrow(ApiError)
})
it('should handle path parameters correctly', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 123 }),
})
await api.get('GET /posts/:id', { id: 123 })
expect(global.fetch).toHaveBeenCalledWith(
'/api/posts/123',
expect.any(Object)
)
})
})
Key Takeaways
By building this type-safe API client, you've achieved:
- Full type inference - No manual type assertions needed
- Compile-time safety - Invalid requests caught before runtime
- Single source of truth - API schema defines everything
- Runtime validation - Optional Zod integration catches API changes
- Better DX - Autocomplete for endpoints, requests, and responses
- Error handling - Structured error types with full context
Next Steps
To extend this pattern:
- Add request interceptors for auth tokens
- Implement retry logic for failed requests
- Add request caching with React Query or SWR
- Generate types from OpenAPI specs automatically
- Add request/response logging for debugging
Conclusion
Type safety doesn't have to end at your application boundary. With TypeScript's advanced type system and careful API design, you can extend strong typing all the way through to your HTTP requests.
The upfront investment in building this client pays dividends in reduced bugs, better developer experience, and more maintainable code.
Your API calls are now as type-safe as the rest of your TypeScript code. No more any types, no more runtime surprises.
Repository: Full working example available at github.com/staticblock/type-safe-api-client
Compatibility: TypeScript 5.0+, works with any JavaScript framework or vanilla JS.
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.