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
Complete Guide: 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 web applications. Build scalable database-driven apps with seamless TypeScript support.

Blog Image
Build Production-Ready GraphQL APIs: Apollo Server, Prisma & TypeScript Complete Developer Guide

Learn to build enterprise-grade GraphQL APIs with Apollo Server, Prisma & TypeScript. Complete guide covering auth, optimization, subscriptions & deployment. Start building now!

Blog Image
How to Build Production-Ready GraphQL APIs with Apollo Server, Prisma, and Redis Caching

Learn to build scalable GraphQL APIs with Apollo Server, Prisma ORM, and Redis caching. Includes authentication, subscriptions, and production deployment tips.

Blog Image
Prisma GraphQL Integration: Build Type-Safe APIs with Modern Database Operations and Full-Stack TypeScript Support

Learn how to integrate Prisma with GraphQL for end-to-end type-safe database operations. Build efficient, error-free APIs with TypeScript support.

Blog Image
Building Event-Driven Microservices with NestJS, RabbitMQ, and MongoDB: Complete Professional Guide

Learn to build scalable event-driven microservices using NestJS, RabbitMQ & MongoDB. Master CQRS, event sourcing, and distributed systems. Start coding now!

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

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