Building TypeScript API Client SDKs in 2026
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.
The patterns here apply equally to internal API clients (the TypeScript layer your frontend talks to) and public-facing SDKs (what you ship on npm for your users). The design principles are the same: typed safety reduces runtime errors, ergonomic abstractions reduce integration time, and thoughtful error handling prevents silent failures that corrupt user data.
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)
}
Environment Compatibility
TypeScript SDKs in 2026 must work in multiple runtime environments without polyfills or conditional imports. The fetch-first approach mentioned in the Key Takeaways makes this significantly easier than the node-fetch or axios era, but there are still runtime-specific considerations.
Node.js 18+: Built-in fetch available. AbortSignal.timeout() available. ReadableStream available for streaming. TypeScript target ES2022 with moduleResolution: node16 covers everything you need.
Cloudflare Workers: fetch and ReadableStream native. No Node.js built-ins (fs, path, crypto — use globalThis.crypto instead). Bundle size is critical — keep dependencies minimal. Workers have a 128MB memory limit per request.
Bun: Full Node.js compatibility for most built-ins. Bun's fetch is native and generally faster than Node.js's. Test your SDK explicitly in Bun if you want to claim Bun support — there are subtle differences in how Bun handles some edge cases in the Fetch API.
Browser (frontend): Don't include server secrets (API keys) in browser-side SDK code. If your SDK is designed for both environments, use an environment check (typeof window !== 'undefined') to gate server-only behavior. Consider a separate package export (my-sdk/browser) that omits server-specific code and reduces bundle size.
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)
Publishing to npm requires a correctly configured package.json with "main" (CommonJS), "module" (ESM), "types" (TypeScript declarations), and "exports" map for dual-CJS/ESM packages. Generate TypeScript declarations with tsc --declaration --emitDeclarationOnly. Test the published package with npm pack and install the resulting .tgz in a fresh project before publishing — this catches missing files declarations and import resolution issues that only appear in real usage.
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}` })
}
}
Testing Your SDK
SDK testing has a unique challenge: you're testing code whose entire purpose is to make HTTP requests to an external service. You need to verify that the right request is constructed and the right response is parsed, without actually calling the external API.
Mock at the HTTP layer, not the method layer: The most robust approach intercepts fetch calls rather than mocking SDK methods. Using msw (Mock Service Worker) in test environments, you can intercept fetch calls and return controlled responses. This tests the full request construction path — URL building, header injection, body serialization — not just the method signature. If your SDK has a bug in how it serializes query parameters, method-level mocks miss it; HTTP-level mocks catch it.
Test the retry logic explicitly: Your retry behavior is some of the most important code in the SDK. Write tests that simulate a 429 response, then verify the SDK retried with exponential backoff. Verify that it respects the Retry-After header when present. Verify that it does NOT retry on 404 (not found) or 422 (validation error) — retrying those is wrong behavior. A common SDK bug is retry logic that retries too aggressively on client errors, hammering the API unnecessarily.
Test idempotency key generation: If your SDK auto-generates idempotency keys for POST requests (a good practice for payment and state-mutation operations), verify that the same key is sent on retries. If a new key is generated on each retry attempt, the idempotency guarantee is broken — the API will treat each retry as a new request rather than a duplicate.
Integration tests against a test server: For final validation, run your SDK against a real test server (either the provider's sandbox or a locally-run mock server). This catches the class of bugs that only appear with real HTTP transport: encoding edge cases, chunked transfer encoding issues, streaming backpressure. Keep these integration tests separate from unit tests and run them on a schedule or before releases, not on every commit.
Versioning and Breaking Changes
SDK versioning is harder than library versioning because your SDK users call your methods directly — there's no contract test between their code and yours. A method rename breaks them silently (TypeScript compilation error) or noisily (runtime error) depending on whether they have good type coverage.
Follow semantic versioning strictly: Breaking changes increment the major version (1.x.x → 2.0.0). Breaking changes include: removing or renaming methods, changing required parameter names, changing response types, removing fields from response types, or changing error class names. Additive changes (new methods, new optional parameters, new response fields) are minor version increments. Bug fixes are patch version increments. Many SDK authors are too conservative about what constitutes "breaking" — this leads to premature major version bumps that fragment the ecosystem.
Deprecation before removal: For any public method you want to remove or change, deprecate it at least one minor version (ideally one major version) before removal. Add a @deprecated JSDoc tag that explains the replacement. TypeScript's @deprecated annotation will show strikethrough in editors and a warning message. Keep the deprecated method working until the removal version — a deprecation that silently breaks is worse than no deprecation.
Changelog discipline: Every breaking change, deprecation, and new feature should appear in the changelog with a migration guide. The Stripe SDK changelog is the gold standard: each entry explains what changed, why it changed, and how to migrate from the old behavior. Developers shouldn't need to diff your source code to understand what changed between versions.
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