js

TypeScript API Clients: Build Type-Safe Apps with OpenAPI Generator and Custom Axios Interceptors

Learn to build type-safe API clients using OpenAPI Generator and custom Axios interceptors in TypeScript. Master error handling, authentication, and testing for robust applications.

TypeScript API Clients: Build Type-Safe Apps with OpenAPI Generator and Custom Axios Interceptors

I’ve been thinking about API clients lately because I’ve spent too many hours debugging type mismatches between frontend and backend systems. Remember those moments when you thought you were sending the right data, only to get a 400 error because of a simple type mismatch? That frustration led me to build type-safe API clients that actually work.

What if your development environment could catch API integration issues before they reach production?

Let me show you how OpenAPI Generator creates type-safe clients from your API specifications. This approach ensures your client code always matches your backend contracts. Here’s a basic setup:

openapi-generator-cli generate \
  -i api-spec.yaml \
  -g typescript-axios \
  -o src/generated \
  --additional-properties=useSingleRequestParameter=true

The generated client gives you fully typed methods for every endpoint. But raw generated clients often lack the practical features needed for real applications. That’s where custom Axios interceptors come into play.

Have you ever needed to add authentication tokens to every request automatically?

Let me demonstrate request interceptors that handle authentication seamlessly:

import { AxiosInstance, InternalAxiosRequestConfig } from 'axios';

export class AuthInterceptor {
  constructor(private axiosInstance: AxiosInstance) {}

  setup() {
    this.axiosInstance.interceptors.request.use(
      (config: InternalAxiosRequestConfig) => {
        const token = localStorage.getItem('authToken');
        if (token && config.headers) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );
  }
}

Response interceptors handle errors consistently across your entire application. How many times have you written the same error handling logic across different API calls?

export class ErrorInterceptor {
  constructor(private axiosInstance: AxiosInstance) {}

  setup() {
    this.axiosInstance.interceptors.response.use(
      (response) => response,
      (error) => {
        if (error.response?.status === 401) {
          window.location.href = '/login';
          return Promise.reject(new Error('Authentication required'));
        }
        
        if (error.response?.status === 429) {
          return this.handleRateLimit(error);
        }
        
        return Promise.reject(this.normalizeError(error));
      }
    );
  }
  
  private handleRateLimit(error: any) {
    const retryAfter = error.response.headers['retry-after'];
    console.warn(`Rate limited. Retry after: ${retryAfter} seconds`);
    return Promise.reject(error);
  }
}

Retry logic transforms unreliable API calls into robust operations. Consider network flakes or temporary server issues - wouldn’t it be better if your client handled these automatically?

export class RetryInterceptor {
  private maxRetries = 3;
  private retryDelay = 1000;

  setup() {
    this.axiosInstance.interceptors.response.use(
      (response) => response,
      async (error) => {
        const config = error.config;
        
        if (!this.shouldRetry(error) || config.retryCount >= this.maxRetries) {
          return Promise.reject(error);
        }

        config.retryCount = config.retryCount || 0;
        config.retryCount += 1;

        await this.delay(this.retryDelay * config.retryCount);
        
        return this.axiosInstance(config);
      }
    );
  }

  private shouldRetry(error: any): boolean {
    return error.code === 'ECONNABORTED' || 
           error.response?.status >= 500;
  }
}

Caching interceptors dramatically improve performance by reducing redundant network calls. Why fetch the same data multiple times when you can serve it from memory?

export class CacheInterceptor {
  private cache = new Map<string, { data: any; timestamp: number }>();
  private cacheTTL = 5 * 60 * 1000; // 5 minutes

  setup() {
    this.axiosInstance.interceptors.request.use(
      (config) => {
        if (config.method?.toLowerCase() === 'get' && config.cacheKey) {
          const cached = this.cache.get(config.cacheKey);
          if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
            return Promise.resolve({
              ...config,
              adapter: () => Promise.resolve({
                data: cached.data,
                status: 200,
                statusText: 'OK',
                headers: {},
                config
              })
            });
          }
        }
        return config;
      }
    );
  }
}

Putting everything together creates a client that’s both type-safe and production-ready:

import { Configuration, TasksApi } from './generated';
import { 
  AuthInterceptor, 
  ErrorInterceptor, 
  RetryInterceptor,
  CacheInterceptor 
} from './interceptors';

export class ApiClient {
  private tasksApi: TasksApi;

  constructor(basePath: string) {
    const config = new Configuration({ basePath });
    const axiosInstance = new AxiosInstance(config);
    
    this.setupInterceptors(axiosInstance);
    this.tasksApi = new TasksApi(config, undefined, axiosInstance);
  }

  private setupInterceptors(instance: AxiosInstance) {
    new AuthInterceptor(instance).setup();
    new ErrorInterceptor(instance).setup();
    new RetryInterceptor(instance).setup();
    new CacheInterceptor(instance).setup();
  }

  async createTask(title: string, description?: string) {
    // TypeScript will enforce the correct parameter types
    return this.tasksApi.createTask({ 
      title, 
      description,
      priority: 'medium' 
    });
  }
}

The beauty of this approach becomes apparent when your API evolves. When the backend team adds new fields or changes requirements, your client code immediately shows type errors where updates are needed. No more silent failures or runtime surprises.

Doesn’t it feel reassuring to know that your IDE will alert you about breaking changes before they cause production issues?

This combination of automated type generation and customizable interceptors creates API clients that are both safe and practical. The types prevent integration errors while the interceptors handle the cross-cutting concerns that make applications robust.

I’d love to hear about your experiences with API client development. What challenges have you faced, and how did you solve them? If this approach resonates with you, please share it with your team and let me know how it works in your projects. Your feedback helps improve these solutions for everyone.

Keywords: TypeScript API client, OpenAPI Generator, Axios interceptors, type-safe API, REST API client, TypeScript OpenAPI, API code generation, HTTP client TypeScript, OpenAPI TypeScript generator, custom Axios interceptors



Similar Posts
Blog Image
Build Event-Driven Microservices with NestJS, RabbitMQ, and Redis: Complete Performance Guide

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & Redis. Master async messaging, caching strategies, and distributed transactions. Complete tutorial with production deployment tips.

Blog Image
Building Event-Driven Microservices with NestJS RabbitMQ and TypeScript Complete Guide

Learn to build scalable event-driven microservices using NestJS, RabbitMQ & TypeScript. Master sagas, error handling, monitoring & best practices for distributed systems.

Blog Image
Building High-Performance Microservices with Fastify TypeScript and Prisma Complete Production Guide

Build high-performance microservices with Fastify, TypeScript & Prisma. Complete production guide with Docker deployment, monitoring & optimization tips.

Blog Image
Build Event-Driven Architecture: NestJS, Kafka & MongoDB Change Streams for Scalable Microservices

Learn to build scalable event-driven systems with NestJS, Kafka, and MongoDB Change Streams. Master microservices communication, event sourcing, and real-time data sync.

Blog Image
Build Distributed Task Queue System with BullMQ Redis TypeScript Complete Production Guide

Learn to build scalable distributed task queues with BullMQ, Redis, and TypeScript. Complete guide covers setup, scaling, monitoring & production deployment. Start building today!

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build powerful web apps with seamless database operations and TypeScript support.