Skip to main content

Building TypeScript API Client SDKs

·APIScout Team
sdktypescriptapi-designdeveloper-experiencenodejs

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 instanceof check against generic Error
  • 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

Comments