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.