How to Build a Professional TypeScript SDK for Any REST API

Design, type-safety, error handling, packaging, and DX for a production SDK

·Matija Žiberna·
How to Build a Professional TypeScript SDK for Any REST API

📋 Complete Sanity Development Guides

Get practical Sanity guides with working examples, schema templates, and time-saving prompts. Everything you need to build faster with Sanity CMS.

No spam. Unsubscribe anytime.

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:

// File: actions/submit-review.ts
const response = await fetch('/api/reviews', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(reviewData),
});

if (!response.ok) {
  const errorData = await response.json().catch(() => ({}));
  throw new Error(errorData.error?.message || 'Request failed');
}
// File: app/api/reviews/route.ts
const response = await fetch(`${API_URL}/reviews?productId=${productId}`, {
  headers: {
    'Authorization': `Bearer ${process.env.API_KEY}`,
    'Content-Type': 'application/json',
  },
});

// Same error handling repeated...

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:

packages/your-sdk/
├── src/
│   ├── client.ts      # Main SDK class
│   ├── types.ts       # TypeScript interfaces  
│   ├── errors.ts      # Error handling
│   └── index.ts       # Public exports
├── dist/              # Compiled JavaScript
├── package.json       # Package configuration
└── tsconfig.json      # TypeScript config

The SDK will provide a clean initialization pattern where developers configure it once and use it throughout their application:

const sdk = new YourSDK({
  apiKey: process.env.API_KEY,
  baseUrl: process.env.API_URL,
});

// Clean, consistent usage everywhere
const reviews = await sdk.getByProduct('123');
const newReview = await sdk.create({ title: 'Great!', rating: 5 });

Setting Up the Package Structure

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.

mkdir packages/your-sdk
cd packages/your-sdk
npm init -y

Configure the package.json for TypeScript compilation and proper exports:

{
  "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:

// File: tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

This configuration ensures strict type checking while producing CommonJS output that works across different Node.js environments.

Defining TypeScript Interfaces

Strong typing is what separates a professional SDK from a simple wrapper. Start by defining all the interfaces your SDK will work with:

// File: src/types.ts
export interface SDKConfig {
  apiKey: string;
  baseUrl: string;
  timeout?: number;
}

export interface RequestFilters {
  page?: number;
  limit?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

export interface CreateItemRequest {
  title: string;
  content: string;
  categoryId: string;
  metadata?: Record<string, any>;
}

export interface UpdateItemRequest {
  title?: string;
  content?: string;
  categoryId?: string;
  metadata?: Record<string, any>;
}

export interface APIResponse<T> {
  success: boolean;
  data: T;
  timestamp: string;
}

export interface PaginatedResponse<T> {
  items: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
    hasNext: boolean;
    hasPrev: boolean;
  };
}

export interface APIItem {
  id: string;
  title: string;
  content: string;
  categoryId: string;
  createdAt: string;
  updatedAt: string;
  metadata: Record<string, any>;
}

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:

// File: src/errors.ts
export class SDKError extends Error {
  constructor(
    message: string,
    public code: string,
    public details?: string,
    public status?: number,
    public originalError?: Error
  ) {
    super(message);
    this.name = 'SDKError';
  }
}

export function handleAPIError(response: Response, data?: any): never {
  if (response.status === 401) {
    throw new SDKError(
      'Invalid or missing API key',
      'AUTHENTICATION_ERROR',
      'Check your API key configuration',
      401
    );
  }

  if (response.status === 404) {
    throw new SDKError(
      'Resource not found',
      'NOT_FOUND',
      data?.error?.message || 'The requested resource was not found',
      404
    );
  }

  if (response.status === 400) {
    throw new SDKError(
      'Bad request',
      'VALIDATION_ERROR',
      data?.error?.message || 'Invalid request data',
      400
    );
  }

  if (response.status === 429) {
    throw new SDKError(
      'Rate limit exceeded',
      'RATE_LIMIT_ERROR',
      'Too many requests. Please try again later.',
      429
    );
  }

  if (response.status >= 500) {
    throw new SDKError(
      'Internal server error',
      'SERVER_ERROR',
      'The API service is temporarily unavailable',
      response.status
    );
  }

  throw new SDKError(
    `HTTP ${response.status}: ${response.statusText}`,
    'HTTP_ERROR',
    data?.error?.message || 'Unknown API error',
    response.status
  );
}

export function handleNetworkError(error: Error): never {
  if (error.name === 'AbortError' || error.message.includes('timeout')) {
    throw new SDKError(
      'Request timed out',
      'TIMEOUT_ERROR',
      'The request took too long to complete',
      undefined,
      error
    );
  }

  if (error.message.includes('fetch')) {
    throw new SDKError(
      'Network connection failed',
      'NETWORK_ERROR',
      'Check your internet connection and try again',
      undefined,
      error
    );
  }

  throw new SDKError(
    '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:

// File: src/client.ts
import {
  SDKConfig,
  RequestFilters,
  CreateItemRequest,
  UpdateItemRequest,
  APIResponse,
  PaginatedResponse,
  APIItem,
} from './types';
import { SDKError, handleAPIError, handleNetworkError } from './errors';

export class YourSDK {
  private config: SDKConfig;

  constructor(config: SDKConfig) {
    if (!config.apiKey) {
      throw new SDKError(
        'API key is required',
        'CONFIGURATION_ERROR',
        'Provide apiKey in SDK constructor'
      );
    }
    
    if (!config.baseUrl) {
      throw new SDKError(
        'Base URL is required',
        'CONFIGURATION_ERROR',
        'Provide baseUrl in SDK constructor'
      );
    }

    this.config = {
      timeout: 30000, // Default 30 second timeout
      ...config,
    };
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.config.baseUrl}${endpoint}`;
    
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);

      const response = await fetch(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 instanceof SDKError) {
        throw error;
      }
      handleNetworkError(error as Error);
    }
  }

  async getAll(filters: RequestFilters = {}): Promise<APIResponse<PaginatedResponse<APIItem>>> {
    const params = new URLSearchParams({
      page: (filters.page || 1).toString(),
      limit: (filters.limit || 10).toString(),
    });

    if (filters.sortBy) {
      params.append('sortBy', filters.sortBy);
      params.append('sortOrder', filters.sortOrder || 'desc');
    }

    return this.request<APIResponse<PaginatedResponse<APIItem>>>(`/items?${params}`);
  }

  async getById(id: string): Promise<APIResponse<APIItem>> {
    if (!id) {
      throw new SDKError(
        'Item ID is required',
        'VALIDATION_ERROR',
        'Please provide a valid item ID'
      );
    }

    return this.request<APIResponse<APIItem>>(`/items/${id}`);
  }

  async create(data: CreateItemRequest): Promise<APIResponse<APIItem>> {
    if (!data.title || !data.content) {
      throw new SDKError(
        'Title and content are required',
        'VALIDATION_ERROR',
        'Please provide both title and content'
      );
    }

    return this.request<APIResponse<APIItem>>('/items', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  async update(id: string, data: UpdateItemRequest): Promise<APIResponse<APIItem>> {
    if (!id) {
      throw new SDKError(
        'Item ID is required',
        'VALIDATION_ERROR',
        'Please provide a valid item ID'
      );
    }

    if (Object.keys(data).length === 0) {
      throw new SDKError(
        'Update data is required',
        'VALIDATION_ERROR',
        'Please provide at least one field to update'
      );
    }

    return this.request<APIResponse<APIItem>>(`/items/${id}`, {
      method: 'PATCH',
      body: JSON.stringify(data),
    });
  }

  async delete(id: string): Promise<APIResponse<{ deleted: boolean }>> {
    if (!id) {
      throw new SDKError(
        'Item ID is required',
        'VALIDATION_ERROR',
        'Please provide a valid item ID'
      );
    }

    return this.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:

// File: src/index.ts
export { YourSDK } from './client';
export { SDKError } from './errors';
export type {
  SDKConfig,
  RequestFilters,
  CreateItemRequest,
  UpdateItemRequest,
  APIResponse,
  PaginatedResponse,
  APIItem,
} from './types';

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:

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:

{
  "dependencies": {
    "@your-org/your-sdk": "file:./packages/your-sdk"
  }
}

For separate projects, publish to a Git repository and reference it:

{
  "dependencies": {
    "@your-org/your-sdk": "https://github.com/your-org/your-sdk.git"
  }
}

Using the SDK in your application becomes clean and consistent:

// File: lib/api-client.ts
import { YourSDK } from '@your-org/your-sdk';

export const apiClient = new YourSDK({
  apiKey: process.env.API_KEY!,
  baseUrl: process.env.API_URL!,
});
// File: actions/items.ts
import { apiClient } from '@/lib/api-client';

export async function createItem(formData: FormData) {
  try {
    const result = await apiClient.create({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      categoryId: formData.get('categoryId') as string,
    });
    
    return { success: true, data: result.data };
  } catch (error) {
    if (error instanceof SDKError) {
      return { success: false, error: error.message };
    }
    throw error;
  }
}

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.

Add the SDK as a submodule to your main project:

git submodule add https://github.com/your-org/your-sdk.git packages/your-sdk

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.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in