Back to Blog
SDKAPIDeveloper ExperienceTypeScript

Designing API Client SDKs

Build SDKs developers love. From architecture to error handling to testing to documentation.

B
Bootspring Team
Engineering
March 20, 2023
6 min read

A well-designed SDK makes API integration effortless. A poorly designed one causes frustration and support burden. Here's how to build SDKs developers love.

SDK Architecture#

1// Clean, intuitive structure 2import { Client } from '@yourcompany/sdk'; 3 4const client = new Client({ 5 apiKey: process.env.API_KEY, 6 baseUrl: 'https://api.example.com', // Optional override 7}); 8 9// Resource-based organization 10const user = await client.users.get('user_123'); 11const orders = await client.orders.list({ userId: user.id }); 12const newOrder = await client.orders.create({ items: [...] }); 13 14// Fluent interface for complex queries 15const results = await client.products 16 .list() 17 .filter({ category: 'electronics' }) 18 .sort('price', 'asc') 19 .limit(20) 20 .execute();

Core Client Implementation#

1interface ClientConfig { 2 apiKey: string; 3 baseUrl?: string; 4 timeout?: number; 5 retries?: number; 6} 7 8class Client { 9 private config: Required<ClientConfig>; 10 private http: HttpClient; 11 12 // Resource accessors 13 readonly users: UsersResource; 14 readonly orders: OrdersResource; 15 readonly products: ProductsResource; 16 17 constructor(config: ClientConfig) { 18 this.config = { 19 baseUrl: 'https://api.example.com/v1', 20 timeout: 30000, 21 retries: 3, 22 ...config, 23 }; 24 25 this.http = new HttpClient(this.config); 26 27 // Initialize resources 28 this.users = new UsersResource(this.http); 29 this.orders = new OrdersResource(this.http); 30 this.products = new ProductsResource(this.http); 31 } 32} 33 34// HTTP client with common functionality 35class HttpClient { 36 constructor(private config: Required<ClientConfig>) {} 37 38 async request<T>(options: RequestOptions): Promise<T> { 39 const response = await this.executeWithRetry(options); 40 return this.handleResponse<T>(response); 41 } 42 43 private async executeWithRetry(options: RequestOptions): Promise<Response> { 44 let lastError: Error; 45 46 for (let attempt = 0; attempt <= this.config.retries; attempt++) { 47 try { 48 const controller = new AbortController(); 49 const timeoutId = setTimeout( 50 () => controller.abort(), 51 this.config.timeout 52 ); 53 54 const response = await fetch(`${this.config.baseUrl}${options.path}`, { 55 method: options.method, 56 headers: { 57 'Authorization': `Bearer ${this.config.apiKey}`, 58 'Content-Type': 'application/json', 59 'User-Agent': `yourcompany-sdk/1.0.0`, 60 ...options.headers, 61 }, 62 body: options.body ? JSON.stringify(options.body) : undefined, 63 signal: controller.signal, 64 }); 65 66 clearTimeout(timeoutId); 67 68 if (response.ok || !this.isRetryable(response.status)) { 69 return response; 70 } 71 72 lastError = new ApiError(response.status, await response.text()); 73 } catch (error) { 74 lastError = error as Error; 75 } 76 77 if (attempt < this.config.retries) { 78 await this.delay(Math.pow(2, attempt) * 1000); 79 } 80 } 81 82 throw lastError!; 83 } 84 85 private isRetryable(status: number): boolean { 86 return status === 429 || status >= 500; 87 } 88 89 private delay(ms: number): Promise<void> { 90 return new Promise((resolve) => setTimeout(resolve, ms)); 91 } 92 93 private async handleResponse<T>(response: Response): Promise<T> { 94 if (!response.ok) { 95 const error = await response.json().catch(() => ({})); 96 throw new ApiError(response.status, error.message, error.code); 97 } 98 99 return response.json(); 100 } 101}

Resource Classes#

1// Base resource with common operations 2abstract class Resource<T, CreateInput, UpdateInput> { 3 constructor(protected http: HttpClient, protected basePath: string) {} 4 5 async get(id: string): Promise<T> { 6 return this.http.request<T>({ 7 method: 'GET', 8 path: `${this.basePath}/${id}`, 9 }); 10 } 11 12 async list(params?: ListParams): Promise<PaginatedResponse<T>> { 13 return this.http.request<PaginatedResponse<T>>({ 14 method: 'GET', 15 path: this.basePath, 16 query: params, 17 }); 18 } 19 20 async create(data: CreateInput): Promise<T> { 21 return this.http.request<T>({ 22 method: 'POST', 23 path: this.basePath, 24 body: data, 25 }); 26 } 27 28 async update(id: string, data: UpdateInput): Promise<T> { 29 return this.http.request<T>({ 30 method: 'PATCH', 31 path: `${this.basePath}/${id}`, 32 body: data, 33 }); 34 } 35 36 async delete(id: string): Promise<void> { 37 await this.http.request<void>({ 38 method: 'DELETE', 39 path: `${this.basePath}/${id}`, 40 }); 41 } 42} 43 44// Specific resource with custom methods 45class UsersResource extends Resource<User, CreateUserInput, UpdateUserInput> { 46 constructor(http: HttpClient) { 47 super(http, '/users'); 48 } 49 50 async getByEmail(email: string): Promise<User | null> { 51 const result = await this.list({ email }); 52 return result.data[0] || null; 53 } 54 55 async updatePassword(id: string, password: string): Promise<void> { 56 await this.http.request({ 57 method: 'POST', 58 path: `${this.basePath}/${id}/password`, 59 body: { password }, 60 }); 61 } 62 63 async listOrders(userId: string): Promise<Order[]> { 64 return this.http.request({ 65 method: 'GET', 66 path: `${this.basePath}/${userId}/orders`, 67 }); 68 } 69}

Error Handling#

1// Custom error types 2class ApiError extends Error { 3 constructor( 4 public statusCode: number, 5 message: string, 6 public code?: string, 7 public details?: Record<string, any> 8 ) { 9 super(message); 10 this.name = 'ApiError'; 11 } 12 13 static isApiError(error: unknown): error is ApiError { 14 return error instanceof ApiError; 15 } 16} 17 18class ValidationError extends ApiError { 19 constructor( 20 message: string, 21 public errors: { field: string; message: string }[] 22 ) { 23 super(400, message, 'VALIDATION_ERROR', { errors }); 24 this.name = 'ValidationError'; 25 } 26} 27 28class RateLimitError extends ApiError { 29 constructor(public retryAfter: number) { 30 super(429, 'Rate limit exceeded', 'RATE_LIMITED', { retryAfter }); 31 this.name = 'RateLimitError'; 32 } 33} 34 35// Usage 36try { 37 await client.users.create({ email: 'invalid' }); 38} catch (error) { 39 if (error instanceof ValidationError) { 40 console.log('Validation errors:', error.errors); 41 } else if (error instanceof RateLimitError) { 42 console.log(`Retry after ${error.retryAfter} seconds`); 43 } else if (ApiError.isApiError(error)) { 44 console.log(`API error: ${error.statusCode} - ${error.message}`); 45 } 46}

Pagination#

1// Async iterator for pagination 2class UsersResource { 3 async *listAll(params?: ListParams): AsyncGenerator<User> { 4 let cursor: string | undefined; 5 6 do { 7 const response = await this.list({ ...params, cursor }); 8 9 for (const user of response.data) { 10 yield user; 11 } 12 13 cursor = response.pagination.nextCursor; 14 } while (cursor); 15 } 16} 17 18// Usage 19for await (const user of client.users.listAll({ role: 'admin' })) { 20 console.log(user.email); 21} 22 23// Collect all 24const allUsers: User[] = []; 25for await (const user of client.users.listAll()) { 26 allUsers.push(user); 27}

TypeScript Types#

1// Export all types for consumers 2export interface User { 3 id: string; 4 email: string; 5 name: string; 6 role: 'user' | 'admin'; 7 createdAt: Date; 8 updatedAt: Date; 9} 10 11export interface CreateUserInput { 12 email: string; 13 name: string; 14 role?: 'user' | 'admin'; 15} 16 17export interface UpdateUserInput { 18 email?: string; 19 name?: string; 20 role?: 'user' | 'admin'; 21} 22 23export interface ListParams { 24 limit?: number; 25 cursor?: string; 26 sort?: string; 27 order?: 'asc' | 'desc'; 28} 29 30export interface PaginatedResponse<T> { 31 data: T[]; 32 pagination: { 33 total: number; 34 hasMore: boolean; 35 nextCursor?: string; 36 }; 37} 38 39// Re-export from index 40export { Client, ClientConfig } from './client'; 41export { ApiError, ValidationError, RateLimitError } from './errors'; 42export * from './types';

Testing Utilities#

1// Mock client for testing 2export function createMockClient(): jest.Mocked<Client> { 3 return { 4 users: { 5 get: jest.fn(), 6 list: jest.fn(), 7 create: jest.fn(), 8 update: jest.fn(), 9 delete: jest.fn(), 10 }, 11 orders: { 12 get: jest.fn(), 13 list: jest.fn(), 14 create: jest.fn(), 15 }, 16 } as unknown as jest.Mocked<Client>; 17} 18 19// Test helpers 20export function mockUser(overrides?: Partial<User>): User { 21 return { 22 id: 'user_123', 23 email: 'test@example.com', 24 name: 'Test User', 25 role: 'user', 26 createdAt: new Date('2024-01-01'), 27 updatedAt: new Date('2024-01-01'), 28 ...overrides, 29 }; 30} 31 32// Usage in tests 33describe('UserService', () => { 34 it('creates user correctly', async () => { 35 const mockClient = createMockClient(); 36 mockClient.users.create.mockResolvedValue(mockUser()); 37 38 const service = new UserService(mockClient); 39 const user = await service.createUser({ email: 'test@example.com', name: 'Test' }); 40 41 expect(mockClient.users.create).toHaveBeenCalledWith({ 42 email: 'test@example.com', 43 name: 'Test', 44 }); 45 expect(user.id).toBe('user_123'); 46 }); 47});

Best Practices#

Design: ✓ Consistent naming conventions ✓ Intuitive method signatures ✓ Full TypeScript support ✓ Comprehensive error types Reliability: ✓ Automatic retries ✓ Configurable timeouts ✓ Rate limit handling ✓ Connection pooling Developer Experience: ✓ Clear documentation ✓ Code examples ✓ Testing utilities ✓ Changelog

Conclusion#

A great SDK feels like a natural extension of the language. Focus on intuitive design, strong typing, proper error handling, and excellent documentation. Provide testing utilities and keep the SDK updated with your API.

Share this article

Help spread the word about Bootspring