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:
- Created a new
HTTPError
class, in order to throw HTTP Errors as they appear; - 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:
- We build a closure that, first, receive the method we want to call and then returns a function where we pass the
url
and thebody
(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:
- Created a
createQuery
function, a closure where I can set a defaulturl
andinit
parameters; - Created a new
query
function, where I use thecreateQuery
function to define the base URL and the default parameters that all requests should have (note the dummygetToken
function that adds a Bearer Token to each request); - 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:
// GET https://dummyjson.com/products/1
api
.get('/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.