Ben Bergstein

Typescript Generics: Writing a node-fetch wrapper

August 07, 2021

Background

For most use cases, built-in and custom types serve the needs of Typescript developers. However, at some point in any sufficiently complex codebase, abstractions are necessary. But how to type abstract or reusable functions? In this tutorial, we will learn to use generic types to do just that!

Tutorial

Use case: fetch client.

Let’s say we are building a project that requires integration with an API endpoint. We have a few parameter and response types:

type SearchMoviesParameters = Partial<{
  q: string
  genre: string
}>

type SearchMoviesResponse = {
  movies: Movie[]
}

type FetchMovieParameters = {
  uuid: string
}

type FetchMovieResponse  = {
  movie: Movie
}

type Movie = {
  uuid: string
  title: string
  director: string
  released: number
}

We could integrate with fetch directly for each request. However, it is DRY-er (“Don’t repeat yourself”) to write a generic function to integrate with fetch, and then write a function to wrap each request. It will also establish a foundation for other requests, allowing us to simply add additional types & write a little wrapper, but still use the same code for shared logic like performing the request.

The generic function

import fetch, { RequestInfo, RequestInit } from 'node-fetch'

const baseUrl = 'https://www.example.com/api'

async function performRequest<Parameters, Response>(
  path: string,
  params: Parameters,
  init?: RequestInit
): Promise<Response> {
  const { headers } = init || {}
  const body = JSON.stringify(params)

  const response = await fetch(`${baseUrl}${path}, {
    ...init,
    headers: {
      ...headers,
      'Content-Type': 'application/json',
    },
    body,
  })
  const json = await response.json()
  return json as Response
}

Let’s break that down.

Breakdown

async function performRequest<Parameters, Response>(

This declares an asynchronous function, performRequest, that requires two generic arguments: Parameters and Response.

Why do we require generic type arguments? Because the input and output of this function varies depending on what request it is using. Different requests have different input and outputs. Generic arguments allow uses of this function to share this code while varying input and output types.

path: string,
params: Parameters,
init?: RequestInit

First, we accept a string, path, which we concatenate with the base URL to arrive at the fully resolved URL for the request.

The real key here is params: Parameters, which specifies that the parameters argument will be of whatever type is specified at the time this function is used. This allows usages to specify that parameters must be of a certain type.

Finally, optionally access any additional arguments to pass along to fetch.

return json as Response

The fetch request itself is straightforward. Once we arrive at the final JSON data by awaiting response.json(), we cast the json as Response, the second type argument.

Using the function

Let’s implement the searchMovies function:

const searchMovies = async (
  params: SearchMoviesParameters
): Promise<SearchMoviesResponse> => performRequest<
  SearchMoviesParameters,
  SearchMoviesResponse
>("/movies/search", params)

Implementation was quite easy! Then to

const movies = await searchMovies(q: "still walking")

Pretty nice!

Wrap up

TypeScript generics can seem intimidating. But without them, refactoring and reusing your code will be very difficult to type. It’s definitely worth getting comfortable using them, especially as they enable strongly-typed integrations with libraries!


© 2020 - 2021 Benjamin Bergstein