Using generics in TypeScript to help with making network requests with window fetch

Using generics in TypeScript to help with making network requests with window fetch

ยท

4 min read

By default, when you use window.fetch to make network requests, you don't get much help from TypeScript. That's because the type that is returned is any.

You can work around this by creating your own type for the response and then casting the any typed response to your defined type.

image.png

Why do this?

If you don't type your responses, you won't be able to use TypeScript to help you, it would instead be like using regular JavaScript. You won't get type hints in your editor when accessing a property that doesn't exist, or using that property as the wrong type.

image.png

Here is some example code we started off with:

const response = await fetch('https://httpbin.org/get')
const data = await response.json()

Typing the response

When you visit the URL https://httpbin.org/get in the browser, you will see the response that looks something like this:

{
  "args": {}, 
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", 
    "Accept-Encoding": "gzip, deflate, br", 
    "Accept-Language": "en-CA,en-US;q=0.7,en;q=0.3", 
    "Dnt": "1", 
    "Host": "httpbin.org", 
    "Sec-Fetch-Dest": "document", 
    "Sec-Fetch-Mode": "navigate", 
    "Sec-Fetch-Site": "none", 
    "Sec-Fetch-User": "?1", 
    "Upgrade-Insecure-Requests": "1", 
    "User-Agent": "Mozilla/5.0 xxxxxxxxxxxxxxx", 
    "X-Amzn-Trace-Id": "Root=xxxxxxxxxxxxxxxxxxxx"
  }, 
  "origin": "xx.xx.xx.xx", 
  "url": "https://httpbin.org/get"
}

With this information, we can make a simple type like this for the properties we care about:

type HttpBinGetResponse = {
  headers: {
    Accept: string
    'User-Agent': string
  }
  url: string
}

Then we can change the existing code to this:

const response = await fetch('https://httpbin.org/get')
const data = await response.json() as HttpBinGetResponse

After that, we can see the error underline.

image.png

image.png

Making typed Get requests

Here's an example typed get request:

/**
 * Make a JSON get request
 * @param url Request endpoint
 * @param headers Optional request headers
 */
 export const makeGet = async <T>(
  url: string,
  headers: Record<string, string> = {}
): Promise<T> => {
  const response = await fetch(url, {
    headers: {
      "Content-Type": "application/json",
      ...headers
    }
  });

  if (!response.ok) {
    return Promise.reject(response.statusText);
  }

  return (await response.json()) as T;
};

Let's go through this:

  • the function makeGet is an asynchronous function that has a generic type T. T is a naming convention for generics. I only use T when there's one generic argument, otherwise I will name them to be more descriptive.
  • the function returns a promise of type T
  • fetch is called with the passed in url with some headers that can optionally be overridden by the caller
  • if the response is not ok, we reject with the status text
  • otherwise we await the JSON response and cast it to T

No deserialization

It's important to know that the casting is a tools-only thing in TypeScript. It's not the same as it is in strictly typed languages like Kotlin, Java, Swift or Rust. In those strictly typed languages, you will get a thrown exception when the deserialization occurs. After that point, you can trust it to be ok. The point of deserialization is when you get a successful response from the network request.

In TypeScript, there is no deserialization. You won't receive any errors after a successful network request. Instead, you'll get an error when you try to access properties that don't exist, or you try to use a property that does exist as a different type. So it's important to make sure that you've carefully written out your type.

Making typed Post requests

Like the above example, we can also type our post requests. In the below example, there are 2 generic types.

/**
 * Make a JSON post request
 * @param url Request endpoint
 * @param body JSON serializable request body
 * @param headers Optional request headers
 */
 export const makePost = async <ReqType, ResType>(
  url: string,
  body: ReqType,
  headers: Record<string, string> = {}
): Promise<ResType> => {
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...headers
    },
    body: JSON.stringify(body)
  });

  if (!response.ok) {
    return Promise.reject(response.statusText);
  }

  return (await response.json()) as ResType;
};

Here are the differences:

  • an extra generic argument, so I am naming the generics ReqType and ResType so that it's easier during the implementation.
  • the ReqType is the type of the payload required, i.e. what you're sending to the endpoint. This is JSON stringified before being passed to fetch.

Example Project

In the below project, I make both a get and a post in TypeScript in React. Here is the standalone code:

export type HttpBinRequest = {
  foo: number;
  bar: boolean;
};

export type HttpBinResponse = {
  args: Record<string, string>;
  data: unknown;
  files: Record<string, string>;
  form: Record<string, string>;
  headers: Record<string, string>;
  json: unknown;
  origin: string;
  url: string;
};

makePost<HttpBinRequest, HttpBinResponse>("https://httpbin.org/post", {
  foo: 100,
  bar: true
})
  .then((data) => {
    setOutput(
      JSON.stringify(
        {
          // headers: data.headers,
          you_sent: data.data
        },
        null,
        2
      )
    );
  })
  .catch((e) => {
    console.error("Post Error", e);
    alert("Unknown error");
  });

You can see the whole example project on Codesandbox.

Watch me Code

If you found this useful and want to watch me code, you can check me out on Twitch.

ย