Last month, I was refactoring a Next.js project where API calls were scattered everywhere. Manual fetch calls in server actions, duplicated error handling in components, and inconsistent response parsing across route handlers. It was a maintenance nightmare. After building a proper SDK to replace this mess, I realized every developer needs to know this process.
Whether you're building an internal API for your team or preparing to open your service to the public, an SDK is the professional standard. It transforms chaotic, ad-hoc API integration into a clean, maintainable interface that any developer can drop into their project and start using immediately.
This guide walks you through creating a production-ready TypeScript SDK from scratch, complete with full CRUD operations, comprehensive error handling, and a development workflow that keeps everything organized.
The Problem with Direct API Calls
Before diving into the solution, let's examine why direct API calls become problematic. In most projects, you'll see patterns like this scattered throughout the codebase:
This approach creates several issues. Authentication logic is duplicated across every file that makes API calls. Error handling follows different patterns depending on who wrote the code. Type safety is minimal, leading to runtime errors. Most importantly, there's no central place to manage API interactions, making changes and debugging difficult.
An SDK solves all of these problems by providing a single, well-designed interface for all API operations.
Designing the SDK Architecture
A professional SDK needs to handle configuration, provide methods for each API operation, manage errors consistently, and offer complete TypeScript support. Here's the architecture we'll build:
Start by creating the directory structure and initializing the package. Choose a location that makes sense for your project - either as a separate repository or within a packages directory if you're using a monorepo approach.
bash
mkdir packages/your-sdk
cd packages/your-sdk
npm init -y
Configure the package.json for TypeScript compilation and proper exports:
json
{"name":"@your-org/your-sdk","version":"1.0.0","description":"TypeScript SDK for Your API","main":"dist/index.js","types":"dist/index.d.ts","files":["dist"],"scripts":{"build":"tsc","dev":"tsc --watch","clean":"rm -rf dist","prepublishOnly":"npm run clean && npm run build"},"keywords":["api","sdk","typescript"],"devDependencies":{"typescript":"^5.0.0","@types/node":"^20.0.0"},"engines":{"node":">=18"}}
The key elements here are the main and types fields pointing to the compiled output, the files array ensuring only built code is included when published, and the prepublishOnly script that ensures fresh builds.
Create the TypeScript configuration that produces clean, compatible output:
Notice how the interfaces are designed to be both flexible and specific. The generic APIResponse type can wrap any data, while specific request interfaces ensure developers provide the correct fields. The optional fields in UpdateItemRequest allow for partial updates, which is common in REST APIs.
These types serve as a contract between your API and the developers using your SDK. They provide autocomplete in IDEs, catch errors at compile time, and serve as documentation for what data shapes to expect.
Implementing Error Handling
Robust error handling distinguishes professional SDKs from simple API wrappers. Create a comprehensive error system that provides meaningful information to developers:
typescript
// File: src/errors.tsexportclassSDKErrorextendsError {
constructor(message: string,
publiccode: string,
publicdetails?: string,
publicstatus?: number,
publicoriginalError?: Error) {
super(message);
this.name = 'SDKError';
}
}
exportfunctionhandleAPIError(response: Response, data?: any): never {
if (response.status === 401) {
thrownewSDKError(
'Invalid or missing API key',
'AUTHENTICATION_ERROR',
'Check your API key configuration',
401
);
}
if (response.status === 404) {
thrownewSDKError(
'Resource not found',
'NOT_FOUND',
data?.error?.message || 'The requested resource was not found',
404
);
}
if (response.status === 400) {
thrownewSDKError(
'Bad request',
'VALIDATION_ERROR',
data?.error?.message || 'Invalid request data',
400
);
}
if (response.status === 429) {
thrownewSDKError(
'Rate limit exceeded',
'RATE_LIMIT_ERROR',
'Too many requests. Please try again later.',
429
);
}
if (response.status >= 500) {
thrownewSDKError(
'Internal server error',
'SERVER_ERROR',
'The API service is temporarily unavailable',
response.status
);
}
thrownewSDKError(
`HTTP ${response.status}: ${response.statusText}`,
'HTTP_ERROR',
data?.error?.message || 'Unknown API error',
response.status
);
}
exportfunctionhandleNetworkError(error: Error): never {
if (error.name === 'AbortError' || error.message.includes('timeout')) {
thrownewSDKError(
'Request timed out',
'TIMEOUT_ERROR',
'The request took too long to complete',
undefined,
error
);
}
if (error.message.includes('fetch')) {
thrownewSDKError(
'Network connection failed',
'NETWORK_ERROR',
'Check your internet connection and try again',
undefined,
error
);
}
thrownewSDKError(
'Unknown error occurred',
'UNKNOWN_ERROR',
error.message,
undefined,
error
);
}
This error handling system provides specific error codes that developers can programmatically handle, detailed messages for debugging, and maintains the original error for advanced debugging scenarios. The never return type ensures TypeScript knows these functions will always throw.
Building the Main SDK Client
Now implement the core SDK class that brings everything together:
typescript
// File: src/client.tsimport {
SDKConfig,
RequestFilters,
CreateItemRequest,
UpdateItemRequest,
APIResponse,
PaginatedResponse,
APIItem,
} from'./types';
import { SDKError, handleAPIError, handleNetworkError } from'./errors';
exportclassYourSDK {
privateconfig: SDKConfig;
constructor(config: SDKConfig) {
if (!config.apiKey) {
thrownewSDKError(
'API key is required',
'CONFIGURATION_ERROR',
'Provide apiKey in SDK constructor'
);
}
if (!config.baseUrl) {
thrownewSDKError(
'Base URL is required',
'CONFIGURATION_ERROR',
'Provide baseUrl in SDK constructor'
);
}
this.config = {
timeout: 30000, // Default 30 second timeout
...config,
};
}
privateasync request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`;
try {
const controller = newAbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const response = awaitfetch(url, {
...options,
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json',
...options.headers,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
let data;
try {
data = await response.json();
} catch {
data = null;
}
if (!response.ok) {
handleAPIError(response, data);
}
return data as T;
} catch (error) {
if (error instanceofSDKError) {
throw error;
}
handleNetworkError(error asError);
}
}
asyncgetAll(filters: RequestFilters = {}): Promise<APIResponse<PaginatedResponse<APIItem>>> {
const params = newURLSearchParams({
page: (filters.page || 1).toString(),
limit: (filters.limit || 10).toString(),
});
if (filters.sortBy) {
params.append('sortBy', filters.sortBy);
params.append('sortOrder', filters.sortOrder || 'desc');
}
returnthis.request<APIResponse<PaginatedResponse<APIItem>>>(`/items?${params}`);
}
asyncgetById(id: string): Promise<APIResponse<APIItem>> {
if (!id) {
thrownewSDKError(
'Item ID is required',
'VALIDATION_ERROR',
'Please provide a valid item ID'
);
}
returnthis.request<APIResponse<APIItem>>(`/items/${id}`);
}
asynccreate(data: CreateItemRequest): Promise<APIResponse<APIItem>> {
if (!data.title || !data.content) {
thrownewSDKError(
'Title and content are required',
'VALIDATION_ERROR',
'Please provide both title and content'
);
}
returnthis.request<APIResponse<APIItem>>('/items', {
method: 'POST',
body: JSON.stringify(data),
});
}
asyncupdate(id: string, data: UpdateItemRequest): Promise<APIResponse<APIItem>> {
if (!id) {
thrownewSDKError(
'Item ID is required',
'VALIDATION_ERROR',
'Please provide a valid item ID'
);
}
if (Object.keys(data).length === 0) {
thrownewSDKError(
'Update data is required',
'VALIDATION_ERROR',
'Please provide at least one field to update'
);
}
returnthis.request<APIResponse<APIItem>>(`/items/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
asyncdelete(id: string): Promise<APIResponse<{ deleted: boolean }>> {
if (!id) {
thrownewSDKError(
'Item ID is required',
'VALIDATION_ERROR',
'Please provide a valid item ID'
);
}
returnthis.request<APIResponse<{ deleted: boolean }>>(`/items/${id}`, {
method: 'DELETE',
});
}
}
This implementation demonstrates several important patterns. The private request method centralizes all HTTP logic, including authentication, timeout handling, and response parsing. Each public method validates its inputs and provides clear error messages. The generic typing ensures full TypeScript support while maintaining flexibility.
The constructor validates required configuration upfront, preventing runtime errors later. The timeout mechanism protects against hanging requests, which is crucial for production applications.
Creating Clean Public Exports
The final step in the SDK structure is creating clean public exports that hide internal complexity:
This export strategy gives developers access to the main SDK class, the error type for exception handling, and all the TypeScript interfaces they need for type safety. Internal implementation details like error handling functions remain private.
Building and Testing the SDK
Install the dependencies and build the SDK:
bash
npm install
npm run build
This generates the dist directory with compiled JavaScript and TypeScript declaration files. The declaration files are crucial - they provide full IntelliSense support and type checking for developers using your SDK.
Test the build by examining the generated files. You should see clean JavaScript output in dist/index.js and comprehensive type definitions in dist/index.d.ts.
Integrating the SDK into Projects
Once built, your SDK can be integrated into any Node.js project. If you're developing within the same codebase, reference it directly in your package.json:
This usage pattern eliminates the scattered fetch calls, inconsistent error handling, and duplicated authentication logic that plagued the original implementation.
Managing SDK Development with Git Submodules
For ongoing development, especially when the SDK needs updates alongside your main application, Git submodules provide an elegant solution. This allows you to maintain the SDK as a separate repository while keeping it easily accessible for local development.
This creates a reference to the SDK repository within your main project, allowing you to edit SDK source code directly while maintaining separate git histories. When you make changes to the SDK, commit them to the SDK repository, then update the submodule reference in your main project.
The workflow becomes: edit SDK code in packages/your-sdk, commit and push changes to the SDK repository, then update the main project to reference the new SDK commit. This keeps both repositories clean while enabling seamless local development.
Expanding Beyond Basic CRUD
While this guide covers the essential CRUD operations, professional SDKs often need additional features. Consider adding batch operations for handling multiple items efficiently, streaming endpoints for large datasets, webhook management for real-time updates, and caching mechanisms for improved performance.
Authentication might need to support multiple methods - API keys, OAuth tokens, or JWT tokens. Rate limiting awareness can help your SDK handle API quotas gracefully. Request retry logic with exponential backoff improves reliability in production environments.
The patterns established in this implementation - centralized configuration, consistent error handling, strong typing, and clean public interfaces - scale naturally to accommodate these advanced features.
The Professional Standard
Building an SDK transforms your API from a collection of endpoints into a professional developer tool. It reduces integration time for new developers, minimizes bugs through consistent patterns, and provides a foundation for documentation and examples.
Whether you're exposing an internal service to your team or preparing for public release, an SDK demonstrates technical maturity and respect for the developers who will use your service. The investment in creating proper TypeScript types, comprehensive error handling, and clean interfaces pays dividends in reduced support requests and faster adoption.
You now have the complete process for creating production-ready SDKs. The architecture scales from simple REST APIs to complex services, and the development workflow keeps everything maintainable as your API evolves.
Let me know in the comments if you have questions about any part of this implementation, and subscribe for more practical development guides.