Douglas Moura

Douglas Moura

Software Engineer

Douglas Moura

Douglas Moura

Software Engineer, Musician and Jiujiteiro.

Using fetch with TypeScript

Published at:Published at:Updated at:

Using fetch with TypeScript

Since fetch) is pratically universally supported on the most used web browsers, we may safely drop the use Axios and other similar libraries in favor of fetch. In this article, I'll create a little wrapper for fetch that adds some conveniences to improve the developer experience.

The code

First, I will create a base function from where all the other shall be derived:

// Extends the error class to throw HTTP Errors (any response with status > 299)
class HTTPError extends Error {}

//            A generic type to type the response
// -----------\/
const query = <T = unknown>(url: RequestInfo | URL, init?: RequestInit) =>
  fetch(url, init).then((res) => {
    if (!res.ok)
      throw new HTTPError(res.statusText, { cause: res })

    return res.json() as Promise<T> // <--- Applying the generic type above
  })

In the code above, we:

  1. Created a new HTTPError class, in order to throw HTTP Errors as they appear;
  2. Use a generic type in order to be able to type the response of the request.

Now, let's extend the query function to enable us to serialize and send data on our requests:

const makeRequest
  // -----------\/ RequestInit['method'] is a union of all the possible HTTP methods
  = (method: RequestInit['method']) =>
    //     | Those two generic types enables us to type the
    // \/--  data input (TBody) and output (TResponse) of the function.
    <TResponse = unknown, TBody = Record<string, unknown>>(url: RequestInfo | URL, body: TBody) =>
      query<TResponse>(url, {
        method,
        body: JSON.stringify(body), // <-- JSON Stringify any given object
      })

In the code above, we:

  1. We build a closure that, first, receive the method we want to call and then returns a function where we pass the url and the body (which is, by default, JSON-stringified) of the request.

At this point, we can use our newly created functions like this:

// Adding type for the Product entity
type Product = {
  id: number
  title: string
  description: string
  price: number
  discountPercentage: number
  rating: number
  stock: number
  brand: string
  category: string
  thumbnail: string
  images: string[]
}

// Getting a single product
const product = await query<Product>('https://dummyjson.com/products/1')
console.log(product)

// Creates a function that makes POST requests
const post = makeRequest('POST')

// Adding a new product
const newProduct = await post<Product, Omit<Product, 'id'>>('https://dummyjson.com/products', {
  title: 'New Product',
  description: 'This is a new product',
  price: 100,
  discountPercentage: 0,
  rating: 0,
  stock: 0,
  brand: 'New Brand',
  category: 'New Category',
  images: [],
  thumbnail: '',
})

console.log(newProduct)

Fully functional, but not very "ergonomic". I believe that our code should also be able to accept a base URL for all the requests, make it easier to add things on the header (like an authorization token) and an easy way to make PATCH, PUT and DELETE requests.

Let's refactor the code above in order to make it easy to add a base URL and pass a common header to all requests:

import { getToken } from 'my-custom-auth'

class HTTPError extends Error {}

const createQuery =
  (baseURL: RequestInfo | URL = '', baseInit?: RequestInit) =>
    <T = unknown>(url: RequestInfo | URL, init?: RequestInit) =>
      fetch(`${baseURL}${url}`, { ...baseInit, ...init }).then((res) => {
        if (!res.ok)
          throw new HTTPError(res.statusText, { cause: res })

         return res.json() as Promise<T>
       })

// This is the function where we define our base URL and headers
const query = createQuery(
  'https://dummyjson.com',
  {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`, // If you need to add a token to the header, you can do it here.
    },
  })


const makeRequest = (method: RequestInit['method']) =>
  <TResponse = unknown, TBody = Record<string, unknown>>(url: RequestInfo | URL, body: TBody) =>
    query<TResponse>(url, {
      method,
      body: JSON.stringify(body),
     })

export const api = {
  get: query,
  post: makeRequest('POST'),
  delete: makeRequest('DELETE'),
  put: makeRequest('PUT'),
  patch: makeRequest('PATCH'),
}

In the code above, I:

  1. Created a createQuery function, a closure where I can set a default url and init parameters;
  2. Created a new query function, where I use the createQuery function to define the base URL and the default parameters that all requests should have (note the dummy getToken function that adds a Bearer Token to each request);
  3. In the end, I export the api object all the commonly used function to make requests.

You may want to return the body of a request that returned an error, like, for example, when your backend returns the standardized problem details. So, the refactored code would be:

import { getToken } from 'my-custom-auth'

// Extends the return of the HTTPError class
class HTTPError extends Error {
  readonly response: any;
  readonly status: number;
  readonly statusText: string;

  constructor(status: number, statusText: string, response: any) {
    super(statusText);
    this.status = status;
    this.statusText = statusText;
    this.response = response;
  }
}

const createQuery =
  (baseURL: RequestInfo | URL = '', baseInit?: RequestInit) =>
    <TResponse = unknown>(url: RequestInfo | URL, init?: RequestInit) =>
      fetch(`${baseURL}${url}`, { ...baseInit, ...init }).then(async (res) => {
        // Now, we get the JSON response early
        const response = await res.json()

        if (!res.ok)
          throw new HTTPError(res.status, res.statusText, response);

         return response as TResponse
       })

// In this function, we define our base URL and headers.
const query = createQuery(
  'https://dummyjson.com',
  {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`, // If you need to add a token to the header, you can do it here.
    },
  })


const makeRequest = (method: RequestInit['method']) =>
  <TResponse = unknown, TBody = Record<string, unknown>>(url: RequestInfo | URL, body: TBody) =>
    query<TResponse>(url, {
      method,
      body: JSON.stringify(body),
     })

export const api = {
  get: query,
  post: makeRequest('POST'),
  delete: makeRequest('DELETE'),
  put: makeRequest('PUT'),
  patch: makeRequest('PATCH'),
}

And now, you can use your new wrapper around fetch like this:

type Product = {
  id: number
  title: string
  description: string
  price: number
  discountPercentage: number
  rating: number
  stock: number
  brand: string
  category: string
  thumbnail: string
  images: string[]
}

// GET https://dummyjson.com/products/1
api
  .get<Product>('/products/1')
  .then(console.log)
  .catch((err) => {
    if (err instanceof HTTPError) {
      // Handle HTTP Errors
      console.error('HTTPError', err);
    }

    if (err instanceof SyntaxError) {
      // Handle error parsing of the response
      console.error('SyntaxError', err);
    }

    console.error('Other errors', err);
});

Final thoughts

The code above is not full-featured as Axios, redaxios, ky or wretch, but, most of the time, it is all need when I'm working with React using SWR or TanStack Query (and on the backend too). Give me your thoughts about the code and show me your improvements (if you want). You can access this code on this gist.

Leave a Reply

Loading comments...