Introducing Gretchen: Making Fetch Happen in TypeScript


Link to the repository --> github.com/truework/gretchen

We use TypeScript at Truework and rely heavily on asynchronous communication between our frontend (React) and our backend (Django). gretchen was born from a need for a modern fetch abstraction that played well with TypeScript. As with most things code, there are a few ways to go about this, and many existing solutions could well have been retrofitted to solve for our use case now and in the future. However, retrofitting felt to us like an imperfect and complex solution to what is otherwise a fairly straightforward problem: using fetch in a type-safe way.

We also needed a solution to a few other common problems in modern web apps:

🔨 Parity with fetch

fetch is widely used, and most devs are familiar with it. We rely on it heavily within the existing codebase, and so we wanted our solution to retain most of the same ergonomics for backward compatibility and familiarity.

👷‍♀️ Resilience

We knew early on that a solution for us would need to be resilient to things like network errors and inconsistent responses returned from our legacy API endpoints. gretchen has solutions to these problems built-in via timeout handling, configurable retry logic, and error normalization.

⚙️ Browser Support

Many users still rely on IE11 for day-to-day work. Instead of transpiling another existing solution as some libraries suggest, we wanted to build a solution with older browsers in mind from the get-go.

Towards a type-safe fetch

Most modern request abstractions work on the assumption that non-success (non 2xx or 3xx) responses are exceptions, which should throw. Hence, most docs will give examples involving Promise.catch or try/catch using async/await syntax.

However, thrown exceptions are difficult to safely type, and in many non-success cases, we usually already know what shape our error will have.

The first most common option is to return typed data structures that assertions can be made upon, and so allow the TypeScript compiler to understand what we’re dealing with.

1
2
3
4
5
6
7
try {
  await getEndpoint();
} catch (e) {
  if (error instanceof SomeError) {
    // handle SomeError type
  }
}

This works fine, but it does require some additional boilerplate and internal knowledge of all the error types that are possible. At Truework, we’re working towards a standardization of error response shapes, so in almost all cases we know we’re going to get a SomeError type and don’t want to have to check for it every time.

The second most common option is simply this: don’t throw exceptions.

This is actually how fetch works by default, and not throwing errors also works really well with TypeScript. For a given endpoint, we want to be able to outline our expectations for both success (2xx) and error (>= 400) responses. If we aren’t throwing exceptions, this assertion can be abstracted away and done automatically.

1
2
3
4
5
if (response.status < 300) {
  return SuccessType(response);
} else {
  return ErrorType(response);
}

This is actually a common pattern in functional languages, often using an Either type. In TypeScript, this is called a “discriminated union”. We actually implemented our own version of Either for a while and found success with it. It looked a little like this.

1
2
3
4
5
6
7
const response = await getEndpoint();

if (response.isLeft()) {
  // handle error
} else if (response.isRight()) {
  // handle success
}

While we liked the stability of this pattern, for newcomers it still introduces a new paradigm that we thought could be simplified. We realized that we could distill this down a more simple pattern and still retain type safety in the absence of exceptions.

Instead of returning an object with methods to check the type of a discriminated union, we employ another common pattern – similar to Go error handling – and simply return both halves of the union as separate properties:

1
2
3
4
5
6
7
const { error, data } = await getEndpoint();

if (error) {
  // handle error
} else {
  // handle data
}

gretch as the new fetch

Using gretchen looks a lot like using native fetch. Instead of something like this:

1
2
3
4
5
6
7
8
9
10
const response = await fetch("/user/12345");

const { ok, status } = response;

if (!ok) {
  // handle error
} else {
  const user = await response.json();
  // handle user
}

You can do something like this:

1
2
3
4
5
6
7
const { status, error, data: user } = await gretch("/user/12345").json();

if (error) {
  // handle error
} else {
  // handle user
}

Non-GET requests should also look very familiar:

1
2
3
4
5
6
7
8
9
10
11
12
const { status, error, data: user } = await gretch("/user/create", {
  method: "POST",
  json: {
    name: "Megan Rapinoe"
  }
}).json();

if (error) {
  // handle error
} else {
  // handle user
}

Internally, gretchen handles most of the boilerplate usually required with fetch. For us, it's proved to be just the right amount of abstraction for our needs. We get maximum flexibility along with some niceties like:

👉 Configuring JSON bodies

gretchen of course supports a body property, but if you're sending JSON, it provides a shorthand via the json property, as seen above. This stringifies the object you pass, and configures the Content-Type header for sending JSON.

👉 Automatically retrying some failed requests

Requests often fail due to things like network issues or server load. By default, gretchen will retry GET requests that return 408, 413, or 429, two times. You can configure the number of retries, and which request methods and status codes trigger a retry using the options object. If your server returns a Retry-After header, gretchen will also respect that delay.

1
2
3
4
5
6
7
8
9
10
await gretch("/username/exists", {
  method: "POST",
  retry: {
    attempts: 3,
    methods: ["POST"]
  },
  json: {
    username: "m.rapinoe"
  }
}).json();

👉 Handling request timeouts

By default, gretchen will automatically timeout a request at 10 seconds and retry it providing it meets the retry criteria you specify. You can configure timeouts as well, say, to allow uploading of a large file.

👉 Parsing response bodies

gretchen provides an interface on top of all body interface methods: arrayBuffer, blob, formData, json, and text. For example, instead of this:

1
2
const response = await fetch("/file/12345");
const file = await response.blob();

You can do this:

1
const file = await gretch("/file/12345").blob();

👉 Catching exceptions

gretchen is designed not to throw exceptions for failed HTTP requests, but it also won't throw for other common API issues like an endpoint returning invalid JSON. In edge cases like these, gretchen will retain the status of the successful request, but populate the error property with the parsing exception that occurred.

1
2
3
4
5
const {
  status, // 200
  error, // Invalid JSON at...
  data // undefined
} = await gretch("/some/endpoint").json();

👉 Providing global configurability

In addition to passing request level option overrides, gretchen exports a create factory where you can provide default options like headers. It's also a great place to make use of gretchen's concept of "hooks", which fire during different parts of the request lifecycle. These make it easy to provide error logging and debug code to every request in your app.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const gretch = create({
  headers: {
    "X-Powered-By": "gretchen"
  },
  hooks: {
    before(request, options) {
      request.headers.set("X-Tracking-Id", userId);
    },
    after(response, options) {
      const { url, status, error, data } = response;
      if (status >= 400) {
        sentry.captureMessage(`${url} returned ${status}`);
      }
    }
  }
});

Gretchen + TypeScript = 🥰

Typing response and error objects in TypeScript projects is simple, just tell gretch what you’re expecting.

1
2
3
4
5
type UserType = {
  name: string;
};

const { data } = await gretch<UserType>("/user/12345").json();

Because gretchen returns either an error or data, you'll need to do a check to clue the TypeScript compiler in to which object you're using.

1
2
3
if (data) {
  // data is typeof UserType
}

Typing error responses is just as easy, simply pass the error type as the second type parameter.

1
2
3
4
5
type ErrorType = {
  messages: string[];
};

const { error, data } = await gretch<UserType, ErrorType>("/user/12345").json();

Altogether, usage looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type UserType = {
  name: string;
};
type ErrorType = {
  messages: string[];
};

const { error, data } = await gretch<UserType, ErrorType>("/user/12345").json();

if (error) {
  // error is typeof ErrorType
} else if (data) {
  // data is typeof UserType
}

Wrapping Up

There are already a lot of great fetch implementations out there, so allow me to summarize this post with this: gretchen is something that works for us, and could probably work for you. It provides a minimal abstraction for maximal flexibility, while delivering on its goals of resilience and browser support. But you should use what you’re comfortable with or whatever works best for your project.

gretchen is also in its early stages. We’re using it in production here at Truework – about 6M requests/month – but some things will likely change as it matures.

If you’d like to try it out or contribute, please head on over to our Github page. We’d love to hear from you :)



Link to the repository --> github.com/truework/gretchen


Subscribe

Subscribe to the Truework blog for the latest trends, research, and news around human resources.

Products
Employers Verifiers Employees
Company
About Us Security Careers Help Blog
Help Verify an employee

Let's Get Started

Employers

Save your HR team over 1,000 hours per year. Learn More

Verifiers

Complete a verification for any employee. Learn More

Start a Verification
Are you an employee? Request Letter

Does your employer use Truework?

View your verified employment information and see a list of all third parties you have authorized to view your data.