Axios and static types

I love axios. I use it in any project where I need to make REST API calls. I tried using fetch for simpler projects but I love Axios' build-in support for HTTP Error handling, global instances, and configuration options. Since leaving my software engineering immersive program, I've only used Axios with Typescript. Until September of 2022 though, I had a problem. When I would get a response from an Axios request, I didn't have a way to verify the return type matched my function's requirements. If you've used Axios with Javascript or Typescript then you know you've had to manually verify object properties exist on Axios' data property. I've written a lot of code like this:

const transformUser = async () => {
  const response = await axios.get('/api/v3/users');
  if (response?.data) {
    if (response.data?.users) {
      return users.id; // or some other property
    }
  }
};

This is a lot of code to do very little, and now we have to handle edge cases for each property we want to access from our API response.

You can use Axios and cast the data to your target interface with Typescript's as keyword.

interface User {
  id: string;
  name: string;
  isActive: boolean;
}

const response = (await axios
  .get('/api/v3/users')
  .then(response => response.data)) as User[];

This is what I used to do, but now we have a new problem. When you use the as keyword you force the interpreter to assume that the data is of type User. If a property is absent or of the wrong type an error occurs at runtime, which is the problem Typescript was designed to solve. If you build a full-stack app and you control both the front-end and back-end, you probably won't run into this problem. If there is a network error, this code isn't worse-off than if you hadn't used it, because Axios (unlike fetch) would throw an error based on the HTTP response. Eventually someone will make a change to your API or you'll run into an edge case that breaks your function.

Setting type generics

The developers of Axios are very smart and they built in a way to statically type your data response using generics. Typescript generic types are a way to handle uncertainty. Generics capture a function's type argument in a reusable way. You can specify the generic type of any Typescript function by adding it in angle brackets before the argument like this:

function myFunc<Type>(arg: Type): Type {
  return arg;
}

When calling this function, you specify a generic argument and both the input type and the return type are static:

const myResult = myFunc<number>(3); // returns 3 as a number

You can use any type as the generic argument for this function, you aren't limited to Typescript primitives. Axios' response object uses a data property that looks like this:

export interface AxiosResponse<T = any, D = any> {
  data: T;
  status: number;
  statusText: string;
  headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
  config: AxiosRequestConfig<D>;
  request?: any;
}

Note that T and D above are used as generics for the interface AxiosResponse along with the initializer type of any. If you don't specify a generic, this means the properties default to type any. This is why we have to "manually" check each property in our Axios response object, like we did in the first example. But if you specify a generic you can use a custom type or interface so any subsequent results will be of type T. Let's add our User interface as the generic type and update the previous example:

interface Users {
  id: string;
  name: string;
  isActive: string;
}

const response = await axios
  .get<Users[]>('/api/v3/users')
  .then(response => response.data); // returns an array with our users

When the generic type argument is defined for any of the Axios function methods, the data property will automatically be converted to the same type, so any functions that want to access the response variable will know what properties are available. We no longer have to manually check each property is present in our data. This is a significant improvement over the first example, where any subsequent access to this object means we won't catch missing properties. Type generics help move possible errors back to build-time, where they should stay.

There is still a small (read: BIG) problem with applying type generics to our Axios function. If our external API changes, our function will still throw errors at run time. Typescript, nor Axios, actually know what the data from the API is while it runs. As long as the response conforms to our type generic and our Typescript functions can use the properties present on the object, we can transpile our code and run it as normal. However, If the API changes, we reintroduce runtime errors and our users won't be able to access the application. In production, it's not enough to rely on the generic type to trust the correct data is coming back from your API. Especially if the remote endpoint isn't one that you can directly control.

Zod: Verify data at runtime

Zod also supports Javascript validation so choose either language to validate your incoming data. Zod works by taking a data schema like the Users interface and turning it into a new instance that can parse your data.

import { z } from 'zod';

const User = z.object({
  id: z.string(),
  name: z.string(),
  isActive: z.boolean(),
});

const john = {
  id: 'U123456',
  name: 'John Doe',
  isActive: true,
};

User.parse(john); // returns our object

Zod parses the actual data response we'd get from our API or whatever external data source, instead of relying simply on the type inference. Now we know that our object conforms to the schema we defined because we tested it. In that example we had to define our object manually, but Zod also generates a Typescript type with the inference method:

import { z } from 'zod';

const User = z.object({
  id: z.string(),
  name: z.string(),
  isActive: z.boolean(),
});

type User = z.infer<typeof User>; // our user

Now we have a new type based on our Zod schema that we can reuse in our application. At this point we've defined our data schema, our expected resposne from the remote endpoint, and an object that can test our data at runtime and check for errors. Here's how you can put it all together:

import axios from 'axios';
import { z } from 'zod';

const User = z.object({
  id: z.string(),
  name: z.string(),
  isActive: z.boolean(),
});

type TUser = z.infer<typeof User>;

const response = await axios
  .get<TUser[]>('/api/v3/users')
  .then(response => response.data);

const validatedUsers = response.map(eachUser => User.parse(eachUser)); // returns an array of parsed users, throws an error if a user doesn't match

There's a separate array method we could also use to parse the array:

const User = z.object({
  id: z.string(),
  name: z.string(),
  isActive: z.boolean(),
});

const ZUserArray = z.array(User);

const response = await axios
  .get<TUser[]>('/api/v3/users')
  .then(response => response.data);

const validatedUsers = ZUserArray.parse(response); // does what we did above with .map()

We explored how to apply generic types to our Axios calls so we can statically type data returned from remote endpoints. We also learned how to validate incoming data at runtime to prevent errors once we've statically typed our incoming data. Accessing and parsing API data is a common feature in most applications, so it's great to take advantage of all the available tools in our Typescript arsenal to prevent bugs. I hope this was informative for you and that you start updating your Axios calls to use the built-in type generics.