Building a typed SDK is one of the most practical investments you can make when scaling a platform with multiple teams or external partners. I've shipped internal API clients at companies with strict type safety requirements, and the difference between a sloppy wrapper and a well-designed SDK is the difference between developers shipping features and developers debugging at 2 AM. This article walks through the patterns I use to keep SDKs maintainable, type-safe, and easy to consume.
Generate from OpenAPI — Types and methods always match the API
The biggest risk with hand-written SDKs is drift. Your API evolves, you update the docs, but the SDK code gets out of sync. The fix is automation: generate your types and methods from a canonical source, and that source should be your OpenAPI specification.
I've seen teams maintain OpenAPI specs religiously because every endpoint change touches a single file. When you run code generation off that spec, your TypeScript types are always current. The alternative—hand-rolling interfaces for every endpoint—doesn't scale past five or six routes.
Here's a realistic workflow. You define your API in OpenAPI 3.0:
openapi: 3.0.0
info:
title: E-commerce API
version: 2.0.0
paths:
/products/{id}:
get:
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Product details
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Not found
components:
schemas:
Product:
type: object
properties:
id:
type: string
name:
type: string
price:
type: number
stock:
type: integer
required:
- id
- name
- price
Now run openapi-typescript or orval to generate:
export interface Product {
id: string;
name: string;
price: number;
stock?: number;
}
export interface ProductsIdGetResponse {
status: 200;
body: Product;
}
export interface ProductsIdGet404Response {
status: 404;
body: { error: string };
}
Your SDK methods become thin wrappers that accept typed inputs and return typed outputs:
export class ECommerceSDK {
async getProduct(id: string): Promise {
const response = await this.request(`/products/${id}`, {
method: 'GET',
});
return response.body;
}
}
The payoff: if your API returns a new required field on Product, the generated types change immediately. Any SDK consumer who forgets to handle the new field gets a compile error before deployment.
Small runtime — Wrap fetch with auth, retries and errors
An SDK's runtime code should be minimal. Your job is to wrap fetch or axios with three concerns: authentication, retries, and error handling. Everything else belongs in your application code.
I keep the runtime to about 150–200 lines. Here's a pattern that works:
interface SDKConfig {
baseURL: string;
apiKey: string;
maxRetries?: number;
retryDelay?: number;
timeout?: number;
}
interface RequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: unknown;
headers?: Record;
}
export class BaseSDK {
private config: SDKConfig;
constructor(config: SDKConfig) {
this.config = {
maxRetries: 3,
retryDelay: 1000,
timeout: 10000,
...config,
};
}
protected async request<T>(
path: string,
options: RequestOptions
): Promise {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= this.config.maxRetries!; attempt++) {
try {
const response = await this.fetchWithTimeout(path, options);
if (response.ok) {
return response.json() as Promise<T>;
}
// Non-2xx responses throw so we can retry idempotent operations
if (response.status >= 500 || response.status === 408 || response.status === 429) {
if (attempt < this.config.maxRetries!) {
await this.delay(this.config.retryDelay! * Math.pow(2, attempt));
continue;
}
}
const errorBody = await response.json().catch(() => ({}));
throw new SDKError(
response.status,
errorBody.message || response.statusText,
errorBody
);
} catch (error) {
lastError = error as Error;
if (!(error instanceof SDKError)) {
// Network or JSON parse errors retry once
if (attempt < 1) {
await this.delay(100);
continue;
}
}
throw error;
}
}
throw lastError;
}
private async fetchWithTimeout(
path: string,
options: RequestOptions
): Promise {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
this.config.timeout
);
try {
return await fetch(`${this.config.baseURL}${path}`, {
method: options.method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
This handles the hard cases: exponential backoff for transient failures, request timeouts, and consistent authentication headers. Every generated method inherits this behavior for free. Keep the runtime focused—resist the urge to add response interceptors, custom serialization, or plugin systems. Those belong in larger frameworks.
Typed errors — Return error types callers can handle
Error handling in SDKs is where friction builds fast. If callers get generic strings, they'll wrap everything in try-catch and log. If callers get rich error types, they can handle 404s differently from 503s.
Define an error hierarchy and return it from every method:
export class SDKError extends Error {
constructor(
public status: number,
public message: string,
public details: unknown
) {
super(message);
this.name = 'SDKError';
}
}
export class NotFoundError extends SDKError {
constructor(details: unknown) {
super(404, 'Not found', details);
this.name = 'NotFoundError';
}
}
export class ValidationError extends SDKError {
constructor(
public fields: Record<string, string[]>,
details: unknown
) {
super(400, 'Validation failed', details);
this.name = 'ValidationError';
}
}
export class RateLimitError extends SDKError {
constructor(
public retryAfter: number,
details: unknown
) {
super(429, 'Rate limited', details);
this.name = 'RateLimitError';
}
}
Then in your request handler, map status codes to error types:
protected async request<T>(
path: string,
options: RequestOptions
): Promise<T> {
// ... retry logic ...
try {
const response = await this.fetchWithTimeout(path, options);
if (response.ok) {
return response.json() as Promise<T>;
}
const errorBody = await response.json().catch(() => ({}));
switch (response.status) {
case 404:
throw new NotFoundError(errorBody);
case 400:
throw new ValidationError(errorBody.fields || {}, errorBody);
case 429:
throw new RateLimitError(
parseInt(response.headers.get('Retry-After') || '60', 10),
errorBody
);
default:
throw new SDKError(response.status, errorBody.message || response.statusText, errorBody);
}
} catch (error) {
// ... handling ...
}
}
Now consumers write precise error handling:
try {
const product = await sdk.getProduct(id);
} catch (error) {
if (error instanceof NotFoundError) {
res.status(404).send('Product not in our catalog');
} else if (error instanceof ValidationError) {
res.status(400).json({ fields: error.fields });
} else if (error instanceof RateLimitError) {
res.status(429).set('Retry-After', error.retryAfter).send('Please wait');
} else {
throw error; // unexpected, let it propagate
}
}
This pattern makes error handling explicit and testable. You're not guessing what went wrong—the error type tells you.
Version carefully — Semver and changelogs for consumers
An SDK is a contract with your consumers. Breaking changes without warning turn SDKs into upgrade nightmares. Follow semantic versioning strictly and communicate changes clearly.
I use a single changelog file at the root:
# Changelog
All notable changes to this project will be documented in this file.
## [2.1.0] - 2024-01-15
### Added
- `listProductsByCategory(category: string)` method.
- Support for `X-Idempotency-Key` header in mutation methods.
### Changed
- `getProduct()` now returns `Promise<Product | null>` instead of throwing NotFoundError. Use `if (product === null)` for missing products.
### Deprecated
- `Product.sku` field will be removed in 3.0.0. Use `Product.id` instead.
### Removed
- Support for Node.js 14. Requires Node.js 16+.
### Fixed
- Fixed race condition in retry logic when multiple requests failed.
## [2.0.0] - 2023-11-20
### Breaking Changes
- Renamed `fetchProduct(id)` to `getProduct(id)`.
- Error shape changed: `{ code, message }` → `SDKError` with typed subclasses.
When you release, tag the commit and publish to npm with the new version:
// package.json
{
"name": "@company/ecommerce-sdk",
"version": "2.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"node-fetch": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"openapi-typescript": "^6.0.0"
}
}
For internal SDKs, I also maintain a migration guide when breaking changes are unavoidable. Show before-and-after code snippets so teams know exactly what to update. Coordinate with consuming teams before shipping major versions.
Common mistakes
- Over-abstracting the runtime. Resist adding middleware, interceptors, or custom serialization unless you have three use cases requiring it. Keep it boring and predictable.
- Ignoring generated types. Hand-writing types defeats the point. If you generate types from OpenAPI, don't maintain parallel docs or interfaces. Let the generator be the source of truth.
- Swallowing errors silently. If a request fails and you catch it to return a default value, you've hidden a real problem. Be loud about failures; let consumers decide how to handle them.
- Shipping breaking changes without notice. Major version bumps are free. Use them. Document migration steps. Give consuming teams a quarter to upgrade.
- Making the SDK too thick. An SDK is not the place to add business logic, caching strategies, or data transformations. Those belong in application code where they can be tested and reasoned about independently.
Key takeaways
- Generate your types and methods from OpenAPI to guarantee correctness and reduce manual maintenance.
- Keep your runtime small: wrap fetch with auth, retries, and timeout handling, then stop.
- Define typed error classes so consumers can handle failures precisely without guessing.
- Use semantic versioning and detailed changelogs. Breaking changes are fine if you signal them clearly.
- An SDK is a contract. Every decision you make affects multiple teams, so prioritize clarity and stability over cleverness.






Comments
Loading comments…