DEV Community

Cover image for Building a Type-Safe API Client in TypeScript: Beyond Axios vs Fetch
limacodes
limacodes

Posted on

1

Building a Type-Safe API Client in TypeScript: Beyond Axios vs Fetch

So you read my last post about Axios vs Fetch and you're thinking "ok cool, but now what?"

Well…let me tell you something. Choosing your HTTP client was just the beginning.
The real productivity killer? It's when your project grows and your API calls become a mess. Trust me, I've been there.

You know what happens next right? Your APIs start changing, types drift, errors pop up everywhere, and suddenly you're spending more time debugging API calls than building features. Sound familiar?
Let's fix this once and for all…

The Problem Nobody Talks About

First thing first. Your project starts small. A few API calls here and there, maybe some user auth, simple data fetching. Everything works fine with basic fetch or axios calls.

But then reality hits hard:

  • APIs change without warning (classic backend team move)
  • Your TypeScript interfaces don't match what the API actually returns
  • Error handling is all over the place
  • New developers join and have no clue how your API layer works
  • Runtime errors from type mismatches kill your confidence

Are you the type of developer who just keeps adding more try-catch blocks everywhere? Or do you want to solve this properly?

So lets do this…

Quick Reminder: Why Axios Still Wins for Productivity
Before we dive deep, let me remind you why this foundation matters:
Axios just makes your life easier. Period.

  • Automatic JSON parsing (fetch makes you call .json() every time)
  • Built-in error handling (no more checking response.ok manually)
  • Interceptors for global logic (auth tokens, logging, whatever)
  • Request cancellation that actually works
  • Global config that applies everywhere

Yeah fetch is native and smaller, but unless you're building a landing page, axios saves you time. And time is money, right?

Building Something That Actually Scales

Now here's where it gets interesting. Most developers stop at "I'll use axios" and call it a day. But that's like buying a Ferrari and only driving in first gear.

Let me show you how to build an API client that will make your future self thank you…

Step 1: Define Your Types (And Actually Use Them)
Look, TypeScript without proper interfaces is just JavaScript with extra steps. Define your API contract first:

interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
}
interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}
interface ApiError {
  message: string;
  status: number;
  code?: string;
}

Enter fullscreen mode Exit fullscreen mode

Simple? Yes. Powerful? Absolutely.

Step 2: Create Your Base Client (The Smart Way)
Here's where most people mess up. They create a new axios instance everywhere. Don't do that. Build one client that handles everything:

import axios from 'axios';

class ApiClient {
  private client;

  constructor(baseURL, headers = {}) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
        ...headers,
      },
      timeout: 10000,
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // Add auth token automatically
    this.client.interceptors.request.use((config) => {
      const token = localStorage.getItem('authToken');
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    });

    // Handle errors consistently
    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        const apiError = {
          message: error.response?.data?.message || error.message,
          status: error.response?.status || 500,
          code: error.response?.data?.code,
        };
        return Promise.reject(apiError);
      }
    );
  }

  async get(url, config) {
    try {
      const response = await this.client.get(url, config);
      return response.data;
    } catch (error) {
      throw error;
    }
  }

  async post(url, data, config) {
    try {
      const response = await this.client.post(url, data, config);
      return response.data;
    } catch (error) {
      throw error;
    }
  }

  // add put, delete, etc...
}
Enter fullscreen mode Exit fullscreen mode

See what I did there? One place for all your HTTP logic. Auth tokens? Handled. Error formatting? Handled. Timeouts? Handled.

Step 3: Organize by Services (Like a Pro)
Don't dump all your API calls in one file. That's amateur hour. Create services:

class UserService {
  constructor(apiClient) {
    this.api = apiClient;
  }

  async getUsers(page = 1, limit = 10) {
    return await this.api.get(`/users?page=${page}&limit=${limit}`);
  }

  async getUserById(id) {
    return await this.api.get(`/users/${id}`);
  }

  async createUser(userData) {
    return await this.api.post('/users', userData);
  }

  async updateUser(id, userData) {
    return await this.api.put(`/users/${id}`, userData);
  }

  async deleteUser(id) {
    return await this.api.delete(`/users/${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Clean. Organized. Maintainable. Your team will love you.
Step 4: Put It All Together
Create a factory that gives you everything:

class ApiFactory {
  constructor(baseURL, headers = {}) {
    this.apiClient = new ApiClient(baseURL, headers);
    this.userService = new UserService(this.apiClient);
  }

  get users() {
    return this.userService;
  }

  // Add more services as you need them
}

// Usage in your app
const api = new ApiFactory(process.env.REACT_APP_API_BASE_URL);

export default api;
Enter fullscreen mode Exit fullscreen mode

Now in your components:

const users = await api.users.getUsers();
const user = await api.users.getUserById(123);
Enter fullscreen mode Exit fullscreen mode

Beautiful, right?
Pro Tips That Will Save Your Life
Runtime Validation (Because TypeScript Lies Sometimes)
TypeScript types disappear at runtime. Want real safety? Add runtime checks:

import { z } from 'zod';
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});
// In your service
async getUserById(id) {
  const response = await this.api.get(`/users/${id}`);
  // This will throw if the API returns garbage
  return UserSchema.parse(response.data);
}
Enter fullscreen mode Exit fullscreen mode

Auto-Generate Everything (The Lazy Developer's Dream)
Got an OpenAPI spec? Generate your entire client:

npm install @openapitools/openapi-generator-cli -g
openapi-generator-cli generate -i api-spec.yaml -g typescript-axios -o ./api-client

Boom. Types, methods, documentation. All generated. All type-safe. All maintained automatically.

Handle Loading States Like a Boss

class UserService {
  private abortController = null;

  async getUsers(page = 1) {
    // Cancel previous request if user is clicking fast
    if (this.abortController) {
      this.abortController.abort();
    }

    this.abortController = new AbortController();

    try {
      return await this.api.get(`/users?page=${page}`, {
        signal: this.abortController.signal
      });
    } finally {
      this.abortController = null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No more duplicate requests. No more race conditions. Clean UX.

When to Use What (The Real Talk)

Small project, just you coding?
Basic fetch with a thin wrapper is fine. Don't over-engineer.
Medium project, small team?
This typed client approach is perfect. You'll thank me later.
Large project, big team?
Generated clients all the way. Let the machines do the work.
Enterprise with changing APIs?
All of the above plus comprehensive testing and monitoring.
The Bottom Line

Look, choosing between axios and fetch was just step one. Building a maintainable, type-safe API layer? That's where the real productivity gains happen.
This might seem like a lot of setup initially, but here's what you get:

  • Catch API problems at compile time
  • Consistent error handling everywhere
  • New team members understand your API instantly
  • Refactoring becomes safe instead of scary
  • Less debugging, more feature building

The tools are here. The patterns work. The question is: are you going to keep writing spaghetti API code or level up your game?

Your future self will thank you. Your team will thank you. Your users will thank you (because fewer bugs = better experience). I thank you xD

Just my thoughts for anyone dealing with API chaos. What is your approach? Let me know in the comments.

hm...I am thinking about diving into API caching strategies that can make your app 10x faster. Stay tuned

Top comments (2)

Collapse
 
davidinchenko profile image
David Inchenko

I always liked to make requests through Axios and I think there is no alternative yet

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Ok, since the one other commenter here and the author don't seem to have seen something better, I'll be the light-bringer into this darkness that Axios really is.

Killing the Reasons Why Axios is the Winner

I'll go one at a time.

Automatic JSON parsing (fetch makes you call .json() every time)

I don't need a 2MB package for one line of code.

Built-in error handling (no more checking response.ok manually)

This is bad. I have blogged about this. Long story short: You're driving logic via thrown errors. In other words: You are using try..catch as a branching statement. The worst part: It is around a 40% performance hit in Chromium (don't know Firefox, Opera, etc.).

Interceptors for global logic (auth tokens, logging, whatever)

It takes more code to write an Axios interceptor than it takes to do the same job using fetch.

Request cancellation that actually works

Are you trying to imply that cancellation in fetch doesn't? Because you would be wrong. If you're not trying to imply this, then what is this all about?

Global config that applies everywhere

I don't need a 2MB package to get this.

The ApiClient

Now to the ApiClient class. I copied the code in a VS Code window. It is 62 lines in size, and doesn't include shortcut functions for PUT, DELETE, PATCH or HEAD. Now let me show you code that does all that (except for error handling) using the dr-fetch NPM package:

import { DrFetch, type FetchFnInit, type FetchFnUrl, setHeaders } from "dr-fetch";

type ValidationError = {
    code: number;
    message: string;
}

function myFetch(url: FetchFnUrl, init?: FetchFnInit) {
    init ??= {};
    const token = localStorage.getItem('authToken');
    if (token) {
        setHeaders(init, { Authorization: `Bearer ${token}` });
    }
    return fetch(url, init);
}

export const apiClient = new DrFetch(myFetch)
    .for<400, ValidationError[]>()
    .build();
Enter fullscreen mode Exit fullscreen mode

This is 19 lines of code. It is also not missing any shortcut verb functions. It provides GET, POST, PUT, PATCH, DELETE and HEAD shortcut methods. Furthermore, it pre-types HTTP 400 responses. TypeScript will tell you that inside if (response.status === 400), response.body will be an array of validation error objects. More on typing below.

About the Error Handling

This is the code that doesn't have equivalency in the above counter-example:

    // Handle errors consistently
    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        const apiError = {
          message: error.response?.data?.message || error.message,
          status: error.response?.status || 500,
          code: error.response?.data?.code,
        };
        return Promise.reject(apiError);
      }
    );
Enter fullscreen mode Exit fullscreen mode

This is a hot mess. Let's talk about it.

First and foremost, because Axios throws on non-OK responses, which is a very bad thing to do, we could potentially be getting response bodies in response.data that don't have a message property because, perhaps, not all non-OK HTTP responses carry a JSON with said property. For example, HTTP status code 503 (temporarily unavailable) commonly returns text, not JSON.

The above also lacks TypeScript, which is surprising given the title of the article. And why isn't it typed? Because Axios doesn't have the ability to type response bodies depending on the HTTP status code of the response. However, and I don't know if you noticed, dr-fetch CAN type the body differently according to the HTTP response code.

We can do things like this:

const response = apiClient
    .for<200 | 201, { id: number; name: string }>() // Same type for 200 OK and 201 CREATED
    .for<503, string>()
    .put('/api/some-endpoint', {
        id: 1,
        name: 'Updated Name'
    });

if (response.ok) {
    setUpdatedData(response.body); // Body is fully typed as { id: number; name: string }
}
else if (response.status === 400) {
    const errors = response.body; // Body is fully typed as ValidationError[]
    console.error('Validation errors:', errors);
} else if (response.status === 503) {
    console.error('Service unavailable:', response.body); // Body is fully typed as string
} else {
    console.error('Unexpected error:', response.status);
}
Enter fullscreen mode Exit fullscreen mode

All the above gets full Intellisense by virtue of TypeScript's type narrowing mechanism.

Now back to your code snippet again: You write a response interceptor that is powered by a catch block inside Axios to create a new object that is thrown as well, incurring again in the expensive callstack unwinding operation. Basically, another 40+% performance hit, on top of the one that Axios makes you incur in the first place.

Finally, your shortcut functions show an awful thing to do: Catching errors just to throw them again.

Both get and post in your code do try { ... } catch (error) { throw error; }. Why? If you're not doing anything with the error, you don't add a try..catch block. Yes, not exactly code in the "handle errors consistently" part of your code, but I suppose these are related and why I mention these here.

This is why the error handling was omitted from the equivalent API client I presented: The code you presented has inherent flaws that Axios is incapable of coping with, alongside an unnecessary thing to do, which you're presenting as the epitome and pinnacle of how to fetch data "correctly".

But not only this: By being able to type the body depending on the HTTP response, we can create a better user interface: We could show the user validation error messages nicely, with a simple else if (response.status === 400). With the error standardization thing you show, these validation error messages are effectively being discarded.

Summarizing:

  • Axios throws on non-OK responses. This incurs in a 40+% performance hit.
  • The response interceptor incurs in an extra 40+% performance hit.
  • The code cannot possibly be free of TypeScript errors: TypeScript must be complaining about the message property not existing in response.data.
  • Axios is incapable of typing the body of a response depending on the HTTP status code received.
  • The presented code discards potentially useful response bodies, just because they came in non-OK HTTP responses.
  • 62+ lines of code using axios, a package with thousands of lines of code, that can be beaten with 19 LOC using dr-fetch, a 421-LOC NPM package.

Conclusion

With axios you write more code for less value. If there's one popular package that I personally think should be archived, is axios.


I suppose that now comes the list of many, many other things Axios can do. Let's hear them, I suppose.

👋 Kindness is contagious

Explore this insightful write-up, celebrated by our thriving DEV Community. Developers everywhere are invited to contribute and elevate our shared expertise.

A simple "thank you" can brighten someone’s day—leave your appreciation in the comments!

On DEV, knowledge-sharing fuels our progress and strengthens our community ties. Found this useful? A quick thank you to the author makes all the difference.

Okay