Building TypeScript API Client SDKs
Building TypeScript API Client SDKs: Design Patterns in 2026
TL;DR
A well-designed TypeScript SDK is one of the most powerful investments an API company can make — it's the primary touchpoint for every developer using your API. The best SDKs (Stripe, Anthropic, Resend) share a set of design decisions: auto-completion for every parameter and response field, automatic retries with exponential backoff, pagination that feels like iteration, typed errors with actionable error codes, and a streaming API that works ergonomically. This article documents those patterns with real code — whether you're building an internal API client, an open-source SDK, or trying to understand why Stripe's SDK is so enjoyable to use.
Key Takeaways
- OpenAPI → TypeScript codegen works well for straightforward APIs; breaks down for complex authentication, streaming, or custom pagination patterns
- The
fetch-first approach is now the right choice for Node.js SDK internals — works in Node.js 18+, Deno, Bun, Cloudflare Workers, and browsers without polyfills - Typed errors are table stakes in 2026 — users shouldn't need to
instanceofcheck against genericError - Pagination abstractions (async iterators vs manual
next_page_token) have significant DX impact; auto-paginating iterators win on ergonomics - Authentication flexibility matters — support both constructor-time credentials and per-request overrides for multi-tenant use cases
- Never block the event loop with synchronous operations in SDK internals — all network operations must be async
Auto-Generation vs. Handwritten: When to Use Each
The first decision for any API SDK is whether to auto-generate from an OpenAPI spec or write the SDK by hand.
Auto-Generation with OpenAPI
Tools like openapi-typescript and openapi-fetch generate type-safe clients from OpenAPI specs:
# Generate types from OpenAPI spec
npx openapi-typescript https://api.example.com/openapi.yaml -o src/api-types.ts
# Use with openapi-fetch for a zero-config typed client
npm install openapi-fetch
import createClient from 'openapi-fetch'
import type { paths } from './api-types'
const client = createClient<paths>({ baseUrl: 'https://api.example.com' })
// Fully typed — paths, params, request body, and response are all inferred
const { data, error } = await client.GET('/users/{id}', {
params: { path: { id: 'user-123' } },
})
// data is typed as the 200 response schema
// error is typed as the 4xx/5xx response schemas
Auto-generation is excellent when your OpenAPI spec is accurate, comprehensive, and kept in sync with your actual API. It breaks down when:
- Your API has complex authentication (OAuth flows, request signing)
- You need custom retry logic or circuit breaking
- Your streaming endpoints need ergonomic wrappers
- Pagination requires special handling beyond simple offset/cursor
Handwritten SDKs: When They Win
The Stripe, Anthropic, and Resend SDKs are all handwritten — their teams found that the generated code didn't provide the developer experience they wanted. Handwritten SDKs allow:
- Fluent builder patterns
- Custom error types with actionable messages
- Auto-paginating iterators
- Streaming that feels natural in both Node.js and browser environments
SDK Structure: The Core Architecture
A well-structured TypeScript SDK separates concerns clearly:
src/
├── client.ts ← The main client class
├── resources/
│ ├── users.ts ← Resource-scoped methods
│ ├── orders.ts
│ └── products.ts
├── http.ts ← HTTP layer (fetch wrapper, retries)
├── pagination.ts ← Pagination helpers
├── streaming.ts ← Streaming utilities
├── errors.ts ← Typed error classes
└── types.ts ← Shared TypeScript types
The Main Client Class
// client.ts
import { UsersResource } from './resources/users'
import { OrdersResource } from './resources/orders'
import { HttpClient } from './http'
export interface ClientOptions {
apiKey: string
baseUrl?: string
timeout?: number
maxRetries?: number
}
export class ApiClient {
readonly users: UsersResource
readonly orders: OrdersResource
private http: HttpClient
constructor(options: ClientOptions) {
if (!options.apiKey) {
throw new Error('apiKey is required')
}
this.http = new HttpClient({
apiKey: options.apiKey,
baseUrl: options.baseUrl ?? 'https://api.example.com/v1',
timeout: options.timeout ?? 30_000,
maxRetries: options.maxRetries ?? 3,
})
// Pass http to resources so they share the same config
this.users = new UsersResource(this.http)
this.orders = new OrdersResource(this.http)
}
}
// Usage
const client = new ApiClient({ apiKey: process.env.API_KEY })
const user = await client.users.retrieve('user-123')
const orders = await client.orders.list({ customerId: 'user-123', limit: 20 })
The HTTP Layer: Retries and Timeout
The HTTP layer is where retries, timeout, and authentication live:
// http.ts
interface RequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
path: string
body?: unknown
query?: Record<string, string | number | boolean | undefined>
headers?: Record<string, string>
idempotencyKey?: string // For safe retries on POST/PUT
}
export class HttpClient {
private baseUrl: string
private apiKey: string
private timeout: number
private maxRetries: number
async request<T>(options: RequestOptions): Promise<T> {
const url = new URL(options.path, this.baseUrl)
// Add query params
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
}
const headers: Record<string, string> = {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'User-Agent': `my-sdk/1.0.0 node/${process.version}`,
...options.headers,
}
// Idempotency key for safe retries on mutating operations
if (options.idempotencyKey) {
headers['Idempotency-Key'] = options.idempotencyKey
}
return this.requestWithRetry(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
}
private async requestWithRetry(
url: URL,
init: RequestInit,
attempt = 0
): Promise<any> {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(this.timeout),
})
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}))
throw this.buildError(response.status, errorBody)
}
return response.json()
} catch (error) {
if (attempt >= this.maxRetries) throw error
if (!this.isRetryable(error)) throw error
const delay = this.backoffDelay(attempt)
await new Promise(resolve => setTimeout(resolve, delay))
return this.requestWithRetry(url, init, attempt + 1)
}
}
private backoffDelay(attempt: number): number {
const base = 500 * Math.pow(2, attempt) // 500ms, 1s, 2s, 4s...
const jitter = Math.random() * base * 0.2 // ±20% jitter
return Math.min(base + jitter, 30_000) // Cap at 30 seconds
}
private isRetryable(error: unknown): boolean {
if (error instanceof ApiError) {
return [429, 500, 502, 503, 504].includes(error.status)
}
// Network errors (ECONNREFUSED, timeout) are retryable
return error instanceof TypeError // fetch network error
}
}
Typed Errors: The Right Pattern
Bad SDKs throw generic Error objects. Good SDKs have a typed error hierarchy:
// errors.ts
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly requestId: string
constructor(status: number, body: { message: string; code: string; request_id: string }) {
super(body.message)
this.name = 'ApiError'
this.status = status
this.code = body.code
this.requestId = body.request_id
}
}
export class AuthenticationError extends ApiError {
constructor(body: any) { super(401, body) }
}
export class PermissionDeniedError extends ApiError {
constructor(body: any) { super(403, body) }
}
export class NotFoundError extends ApiError {
constructor(body: any) { super(404, body) }
}
export class RateLimitError extends ApiError {
readonly retryAfter: number // Seconds until rate limit resets
constructor(body: any, retryAfter: number) {
super(429, body)
this.retryAfter = retryAfter
}
}
export class InternalServerError extends ApiError {
constructor(body: any) { super(500, body) }
}
// Usage — developers can catch specific error types
try {
const user = await client.users.retrieve('user-123')
} catch (error) {
if (error instanceof NotFoundError) {
console.log('User not found, redirecting to signup')
} else if (error instanceof RateLimitError) {
console.log(`Rate limited, retry after ${error.retryAfter} seconds`)
} else if (error instanceof AuthenticationError) {
console.log('Invalid API key')
} else {
throw error // Re-throw unexpected errors
}
}
Pagination: Auto-Paginating Iterators
The cleanest pagination DX for cursor-based APIs uses async iterators:
// resources/orders.ts
export class OrdersResource {
async list(params: ListOrdersParams): Promise<OrdersPage> {
return this.http.request({ method: 'GET', path: '/orders', query: params })
}
// Auto-paginating async iterator — handles cursor pagination automatically
async *listAll(params: Omit<ListOrdersParams, 'cursor'>): AsyncGenerator<Order> {
let cursor: string | undefined
do {
const page = await this.list({ ...params, cursor, limit: params.limit ?? 100 })
yield* page.data
cursor = page.nextCursor
} while (cursor)
}
}
// Usage — iterate through all orders without managing pagination manually
for await (const order of client.orders.listAll({ customerId: 'user-123' })) {
await processOrder(order)
}
// Collect all into an array (careful with large datasets)
const allOrders = []
for await (const order of client.orders.listAll({ status: 'pending' })) {
allOrders.push(order)
}
Authentication: Supporting Multiple Patterns
Production SDKs need flexible authentication — different customers use different auth patterns, and multi-tenant applications need per-request credential overrides:
// Support both constructor-level and per-request API keys
export class ApiClient {
constructor(private defaultOptions: ClientOptions) {}
// Per-request auth override for multi-tenant applications
withOptions(overrides: Partial<ClientOptions>): ApiClient {
return new ApiClient({ ...this.defaultOptions, ...overrides })
}
}
// Single-tenant usage
const client = new ApiClient({ apiKey: process.env.API_KEY })
// Multi-tenant: create a scoped client per request with the tenant's API key
app.post('/api/data', async (req, res) => {
const tenantKey = await getTenantApiKey(req.user.tenantId)
const tenantClient = client.withOptions({ apiKey: tenantKey })
const data = await tenantClient.data.list()
res.json(data)
})
Supporting Multiple Auth Schemes
Some APIs use multiple auth schemes — API keys for server-to-server, OAuth tokens for user-scoped operations:
type AuthConfig =
| { type: 'apiKey'; apiKey: string }
| { type: 'bearer'; token: string }
| { type: 'oauth'; clientId: string; clientSecret: string }
function buildAuthHeader(auth: AuthConfig): Record<string, string> {
switch (auth.type) {
case 'apiKey':
return { 'X-API-Key': auth.apiKey }
case 'bearer':
return { 'Authorization': `Bearer ${auth.token}` }
case 'oauth':
// Handle token exchange
return {}
}
}
Streaming: Server-Sent Events and Async Iterators
For APIs that return streaming responses (LLM completions, file processing, long-running operations), the SDK should provide both a raw stream and a convenient async iterator:
// streaming.ts
export async function* parseSSEStream(
response: Response
): AsyncGenerator<{ event: string; data: unknown }> {
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
let event = 'message'
for (const line of lines) {
if (line.startsWith('event: ')) {
event = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') return
yield { event, data: JSON.parse(data) }
}
}
}
} finally {
reader.releaseLock()
}
}
// Resource method using streaming
export class CompletionsResource {
async create(params: CreateCompletionParams): Promise<Completion> {
return this.http.request({ method: 'POST', path: '/completions', body: params })
}
async stream(params: CreateCompletionParams): AsyncGenerator<CompletionChunk> {
const response = await fetch(`${this.baseUrl}/completions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.apiKey}` },
body: JSON.stringify({ ...params, stream: true }),
})
if (!response.ok) throw await this.http.buildError(response)
return parseSSEStream(response)
}
}
// SDK user experience — streaming feels natural
const stream = client.completions.stream({ prompt: 'Hello', maxTokens: 100 })
for await (const chunk of stream) {
process.stdout.write(chunk.text)
}
Publishing and Documentation Quality
A great SDK also needs a great README and npm page:
// ✅ README should include:
// 1. Installation (one command)
// 2. Basic example (5 lines max for the happy path)
// 3. Authentication
// 4. Error handling
// 5. Link to full API reference
// The first 10 lines a developer reads determine if they'll use your SDK:
import ApiClient from 'my-sdk'
const client = new ApiClient({ apiKey: process.env.MY_API_KEY })
const user = await client.users.retrieve('user-123')
console.log(user.name)
JSDoc for every public method — TypeScript users will see these in their IDE:
export class UsersResource {
/**
* Retrieves a user by ID.
*
* @param id - The user's unique identifier
* @throws {NotFoundError} If the user doesn't exist
* @throws {AuthenticationError} If the API key is invalid
*
* @example
* const user = await client.users.retrieve('user-123')
* console.log(user.name) // 'Alice Johnson'
*/
async retrieve(id: string): Promise<User> {
return this.http.request({ method: 'GET', path: `/users/${id}` })
}
}
Methodology
- Analysis based on Stripe Node.js SDK (v14.x), Anthropic Node.js SDK (v0.36.x), Resend SDK (v4.x)
- Pattern sources: Stripe SDK design blog posts, OpenAI SDK source code, TypeScript SDK design guides
- npm download data from npmjs.com API, March 2026
Discover API client libraries and SDK generators on APIScout — compare download trends and community adoption.
Related: API Authentication: OAuth2 vs API Keys vs JWT 2026 · API Pagination Patterns: Cursor vs Offset 2026 · Hono vs Fastify vs Express: API Framework 2026