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.