Logo
Published on

How to incrementally add TypeScript response-types to your React/Svelte/Vue/Angular API data fetching

Authors
  • avatar
    Name
    Rohan Hussain
    Twitter

What You Need

  • A React or any other frontend application that fetches data from a backend REST API.
  • A codebase that supports TypeScript. It doesn't matter if it's currently using any and unknown everywhere currently.
  • It almost doesn't matter how you fetch the data, be it using fetch() or axios or ReactQuery or SWR or any other query manager or UI library. This works for them all. I'll use React as an example here.

Q: Will I need to rewrite a lot of code to start doing this? A: No, you can adopt this incrementally starting from the next API endpoint you write/use.

Steps

Step 1: Learn OpenAPI

You need to learn the basics of how to write an OpenAPI spec file. It's ok if you haven't heard of OpenAPI spec files before. If you haven't, read the next heading. If you have, skip the next heading.

What are OpenAPI spec files?

OpenAPI spec files are like blueprints for APIs, written in a way that both humans and computers can understand. They describe how an API works, including what requests you can make, what data you need to send in requests, and what responses you can expect. This makes it easier for developers to build and use APIs consistently and correctly.

Basically, it's documentation for your API. You can write OpenAPI spec in JSON or YAML whichever you prefer. I've seen most people write it in YAML, but personally I prefer JSON.

Step 2: Write an empty OpenAPI spec

Start by creating a simple empty OpenAPI spec file named api-spec.json:

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with no endpoints.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ]
}

In servers, you need to mention the backend servers that your frontend sends requests to. We are using OpenAPI specification v3.0.0 to write this file. Newer versions are available.

Step 3: Add your first endpoint to the spec file

We will add a paths properti which will contain our endoints. Let's create an endpoint that has URL: /sample-endpoint.

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with a basic GET endpoint.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ],
  "paths": {
    "/sample-endpoint": {}
  }
}

This will be a GET request.

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with a basic GET endpoint.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ],
  "paths": {
    "/sample-endpoint": {
      "get": {
        "operationId": "getSampleObject",
        "summary": "Get a sample object",
        "responses": {}
      }
    }
  }
}

Note the operationId. This is required and is important. This will be the name of the frontend function that will be auto-generated that you will use on the frontend to send this request.

The responses are empty. You can define a 200 status code response, and others like 400, 500, etc. For now let's define a 200 successful response:

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with a basic GET endpoint.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ],
  "paths": {
    "/sample-endpoint": {
      "get": {
        "operationId": "getSampleObject",
        "summary": "Get a sample object",
        "responses": {
          "200": {
            "description": "A simple object response",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          }
        }
      }
    }
  }
}

In the schema goes the schema of our response object. Say our response object looks like this:

{
    "id": 1,
    "name": "Rohan",
    "active": true
}

We describe this response in the OpenAPI spec:

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with a basic GET endpoint.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ],
  "paths": {
    "/sample-endpoint": {
      "get": {
        "operationId": "getSampleObject",
        "summary": "Get a sample object",
        "responses": {
          "200": {
            "description": "A simple object response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "example": 1
                    },
                    "name": {
                      "type": "string",
                      "example": "Sample Name"
                    },
                    "active": {
                      "type": "boolean",
                      "example": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

You can also give an example in the spec for better readability:

{
  "openapi": "3.0.0",
  "info": {
    "title": "Sample API",
    "description": "A simple API example with a basic GET endpoint.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.example.com",
      "description": "Main (production) server"
    },
    {
      "url": "https://staging-api.example.com",
      "description": "Staging server"
    }
  ],
  "paths": {
    "/sample": {
      "get": {
        "operationId": "getSampleObject",
        "summary": "Get a sample object",
        "responses": {
          "200": {
            "description": "A simple object response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "example": 1
                    },
                    "name": {
                      "type": "string",
                      "example": "Sample Name"
                    },
                    "active": {
                      "type": "boolean",
                      "example": true
                    }
                  },
                  "example": {
                    "id": 1,
                    "name": "Sample Name",
                    "active": true
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Now our OpenAPI spec file with a single endpoint is ready.

Step 4: Install orval in your project

orval is a frontend API typescript client generator. It takes your OpenAPI spec file as input and spits out a set of wrapper functions/hooks and typescript types for common frontend query managers like ReactQuery, SWR, Svelte Query, Vue Query, etc.

Install it as a dev dependency in your project:

# npm
npm i orval -D

# or yarn
yarn add orval -D

Add a gen-api script to your package.json:

{
  "name": "your-frontend-project",
  "scripts": {
    "gen-api": "orval --input ./api-spec.json --output ./src/api-spec-client.ts",
  },
}

This assumes that the OpenAPI spec file named api-spec.json is in the same directory as the package.json file.

Step 5: Generate the client using orval and the OpenAPI spec

Run:

npm run gen-api

# or yarn
yarn gen-api

You will find a file named api-spec-client.ts generated in your src folder. This file will contain your frontend client. You will see some code in it like:

/**
 * Generated by orval 🍺
 * Do not edit manually.
 * Sample API
 * A simple API example with a basic GET endpoint.
 * OpenAPI spec version: 1.0.0
 */
import axios from 'axios'
import type {
  AxiosRequestConfig,
  AxiosResponse
} from 'axios'


export type GetSampleObject200 = {
  active?: boolean;
  id?: number;
  name?: string;
};

/**
 * @summary Get a sample object
 */
export const getSampleObject = <TData = AxiosResponse<GetSampleObject200>>(
     options?: AxiosRequestConfig
 ): Promise<TData> => {
    return axios.get(
      `/sample`,options
    );
  }

export type GetSampleObjectResult = AxiosResponse<GetSampleObject200>

Step 6: Understand the generated cient

Let's break it down:

export type GetSampleObject200 = {
  active?: boolean;
  id?: number;
  name?: string;
};

This is the TypeScript type of the response of our endpoint. It has three properties, all optional. One is a boolean, one is a number and one is a string.

/**
 * @summary Get a sample object
 */
export const getSampleObject = <TData = AxiosResponse<GetSampleObject200>>(
     options?: AxiosRequestConfig
 ): Promise<TData> => {
    return axios.get(
      `/sample`,options
    );
  }

export type GetSampleObjectResult = AxiosResponse<GetSampleObject200>

getSampleObject is the function that you can use to send this request using axios. Orval generates a client using axios by default. If you want to use fetch or something else, you can write a custom client. See orval documentation for that.

Step 7: Use it in React or your library/framework of choice

Now you can basically call this function from anywhere in your React application:

import React, { useEffect, useState } from 'react';
import { getSampleObject, GetSampleObjectResult } from '~/src/api-spec-client.ts';

const SampleComponent = () => {
  const [data, setData] = useState<GetSampleObjectResult | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await getSampleObject();
        setData(response);
      } catch (err) {
        setError('Error fetching data');
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>{error}</div>;
  }

  return (
    <div>
      <h1>Sample Data</h1>
      {data && data.data ? (
        <div>
          <p>ID: {data.data.id}</p>
          <p>Name: {data.data.name}</p>
          <p>Active: {data.data.active ? 'Yes' : 'No'}</p>
        </div>
      ) : (
        <p>No data available</p>
      )}
    </div>
  );
};

export default SampleComponent;

Note the useEffect() and within it:

const response = await getSampleObject();

This is where orval and the spec pay off. If you hover over response, you will see that its TypeScript typing is available. You can see that it contains the properties you defined in your spec, i.e. id, name, active.

If you use them the wrong way or access a property that doesn't exist, you'll get a compile-time error.

Step 8: Make it your own

To make it work with React Query or whatever library you're using, follow Orval's documentation.

Also note that this was just an example. We used no config file for orval. You should use a config file to define the input and output, unlike how we defined using the CLI.

Incrementally Introduce Typing to your Codebase

Slowly add more and more of your existing APIs to the OpenAPI spec file, run orval, and instead of calling them directly in your frontend code like fetch("/some/endpoint"), call them using the orval client functions getSomeEndpoint().