Creating a simple, flexible, and resilient fetch implementation in TypeScript.
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:
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.
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.
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.
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.
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.
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.
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:
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:
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:
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:
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:
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.
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.
await gretch("/username/exists", {
method: "POST",
retry: {
attempts: 3,
methods: ["POST"]
},
json: {
username: "m.rapinoe"
}
}).json();
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.
gretchen
provides an interface on top of all body interface methods:
arrayBuffer
, blob
, formData
, json
, and text
. For example, instead of this:
const response = await fetch("/file/12345");
const file = await response.blob();
You can do this:
const file = await gretch("/file/12345").blob();
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.
const {
status, // 200
error, // Invalid JSON at...
data // undefined
} = await gretch("/some/endpoint").json();
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.
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}`);
}
}
}
});
Typing response and error objects in TypeScript projects is simple, just tell
gretch
what you’re expecting.
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.
if (data) {
// data is typeof UserType
}
Typing error responses is just as easy, simply pass the error type as the second type parameter.
type ErrorType = {
messages: string[];
};
const { error, data } = await gretch<UserType, ErrorType>("/user/12345").json();
Altogether, usage looks like this:
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
}
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 :)
Join the group of 15,000 organizations that use Truework to increase applicant conversion with faster income and employment verifications.
Sign Up For a Demo