Skip to content

Building a REST client

When consuming REST APIs you can use the built-in Fetch API or opt for a third-party library.

An implementation based on the Fetch API is preferred, due its lightweight nature and lack of external dependencies, making it ideal for minimal and maintainable applications. However, using the Fetch API alone, without any library, has its limitations. It easily leads to verbose syntax, awkward error handling and there is no built-in support for middleware.

Wretch addresses these issues by providing a more expressive API, enabling fluent method chaining, automatic response parsing, and cleaner error handling, reducing boilerplate:

ts
import wretch from "wretch";

const bookStoreClient = wretch()
  .url("https://my-book-store.com")
  .headers({ "X-Some-Api-Key": "..." });

const books = await bookStoreClient
  .get("/api/v1/books")
  .json();

await bookStoreClient
  .body({ isbn: "1234", title: "My book" })
  .put("/api/v1/books");

What about Axios?

In the past, Axios was the go-to solution for making HTTP requests in JavaScript and TypeScript. However, its implementation is based on XMLHttpRequest, requiring more code and increasing your bundle size. It also has no automatic JSON handling, and requires more boilerplate code. Therefore, we recommend a more modern library instead.

Pre-configured REST client from Backpack

You can use the pre-configured REST client from @backpack/rest-client, which is based on Wretch and ships with a few add-ons and middlewares enabled by default:

ts
import { createRestClient } from "@backpack/rest-client";

const bookStoreClient = createRestClient({
  baseUrl: "https://my-book-store.com",
  apiManagementApiKey: '...',
  timeout: 5_000,
  // ...
});

const books = await bookStoreClient
  .get("/api/v1/books")
  .json();

Check out the API reference of @backpack/rest-client to learn more.

Response validation

One way to make your response data type-safe, is by passing a generic type to the json() method:

ts
import { createRestClient } from '@backpack/rest-client';

const books = await createRestClient()
  .get("/api/v1/books")
  .json<BooksResponse>();

books
// ^ inferred as `BooksResponse`, but unsafe! ⚠️

However, since you are now basically type-casting your JSON result, it might be better to perform actual runtime validation to the response instead.

To achieve this, we recommend you to parse the result against a schema, using Zod:

ts
import { z } from "zod";
import { createRestClient } from '@backpack/rest-client';

// define a Zod schema for your response
const BooksResponse = z.object({
  isbn: z.string()
  // ...
});

const books = await createRestClient()
  .get("/api/v1/books")
  .json()
  .then(json => BooksResponse.parse(json));
//      ^ `json` is `unknown` but can be parsed by Zod

books;
// ^ still inferred as `BooksResponse`, but now it's safe! 🚀

With the pre-configured REST client from Backpack, you can even use the shorthand .validatedJson() instead:

ts
const books = await createRestClient()
  .get("/api/v1/books")
  .validatedJson(BooksResponse);

For more information on Zod and validation, see Schema validation.

Configuring a timeout

To prevent hanging requests and handle failures gracefully, it is important to configure a timeout for your REST client.

With Wretch, you can achieve this by using the AbortAddon:

ts
import wretch from 'wretch';
import AbortAddon from 'wretch/addons/abort';

const restClient = await wretch()
  .addon(AbortAddon())
  .setTimeout(3_000) // 3 seconds

const books = await restClient
  .get("/api/v1/books")
  // additional helper method to recover from an aborted request:
  .onAbort(() => console.log("Aborted!"))

If you use the pre-configured REST client from @backpack/rest-client, you don't have to worry about this:

ts
import { createRestClient } from '@backpack/rest-client';

const restClient = createRestClient({
  timeout: 3_000
});

const books = await restClient.get("/api/v1/books")

Error handling

How errors should be handled in your REST client, depends on the type of error and your use case.

To handle expected errors such as HTTP 404 responses gracefully, you can use the built-in helper methods of Wretch:

ts
import { createRestClient } from '@backpack/rest-client';

const result = await createRestClient()
  .get("/api/v1/books")
  .notFound(() => {
    // recover from a HTTP 404
    return "some fallback";
  })
  .json()

Another way to handle errors is to propagate them through a chain of Promises:

typescript
const result = createRestClient()
  .get("/api/v1/books")
  .json()
  .then(json => BooksResponse.parse(json))
  .then(data => /* do other stuff */)
  .catch(error => {
    logger.warn('Got unexpected error', error);
    return "some fallback";
  })

With the AsyncResult utility from @backpack/error-handling you can even handle every kind of error differently:

typescript
import { createRestClient } from '@backpack/rest-client';
import { AsyncResult } from "@backpack/error-handling/async-result";

const result = await AsyncResult
  .try(() =>
    createRestClient()
      .get("/api/books")
      .json()
  )
  .map((json) => BooksResponse.parse(json))
  .map(data => /* do other stuff */)
  .onFailureIfInstanceOf(ZodError, (error) => /* ... */)
  .onFailureIfInstanceOf(WretchError, (error) => /* ... */)
  .recover(() => "some fallback");

Logging

Given how easily logging becomes opinionated, the default REST client in Backpack avoids it entirely.

However, if you do want to add some logging, you can easily do so using a middleware function. If you are using the Powertools Logger, you can use the existing logging middleware from Backpack:

ts
import {
  createRestClient,
  powertoolsLoggingMiddleware
} from "@backpack/rest-client";

const myRestClient = createRestClient()
  .middlewares([
    powertoolsLoggingMiddleware({
      logger: myLogger, // pass your logger instance to the middleware
      logRequests: true,
      // to see all options, check the API reference
    })
  ]);

const result = myRestClient 
  .get("/api/books")
  .json()

Alternatively, you can easily add your own middleware:

ts
import type { FetchLike, WretchOptions } from "wretch";

export const myMiddleware = (
  // ... additional options can be passed here
) => {
  return (next: FetchLike): FetchLike =>
    async (url: string, options: WretchOptions) => {
      // do something with the request...

      const response = await next(url, options);

      // do something with the response...

      return response;
    };
};