0% read
Skip to main content
Building a Type-Safe API Client in TypeScript - A Complete Guide

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.

S
StaticBlock Editorial
15 min read

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 posts
  • GET /posts/:id - Get single post
  • POST /posts - Create new post
  • PUT /posts/:id - Update post
  • DELETE /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]) =&gt; {
    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(() =&gt; ({}))
  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&lt;K&gt;

  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 () =&gt; 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:

  1. Full type inference - No manual type assertions needed
  2. Compile-time safety - Invalid requests caught before runtime
  3. Single source of truth - API schema defines everything
  4. Runtime validation - Optional Zod integration catches API changes
  5. Better DX - Autocomplete for endpoints, requests, and responses
  6. Error handling - Structured error types with full context

Next Steps

To extend this pattern:

  1. Add request interceptors for auth tokens
  2. Implement retry logic for failed requests
  3. Add request caching with React Query or SWR
  4. Generate types from OpenAPI specs automatically
  5. 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.

Found this helpful? Share it!

Related Articles

S

Written by StaticBlock Editorial

StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.