A functional approach to promises in typescript
We recenty finished building a website in React (a first for me) and typescript at work. It allows user to update, view and create some stuff. Your basic CRUD website.
One part of the site was the code that does all the API calls. This is of course done using fetch. A function would look something like this.
const getUser = (userId: string): Promise<User> => {
return fetch(`${apiURL}/user/${userId}`).then((res) => res.json);
}
In reality we have the fetch call wrapped with a function that does a bit of work but the essence of what we are doing is shown above.
Then you will call the above function to get the data and show it.
// in some react component
useEffect(() => {
getUser(userId).then((user) => {
setUser(user)
}).catch((reason) => {
setError(reason)
});
}, []);
Basically if we get some data then present that data, if we get some failure then we show that to the user. This code is basically fine. The biggest thing that I really dislike about working with promises like this in typescript is that you will loose type information, and of course the big reason to use typescript is to have a nice type system working for you. Let’s see what types are inferred in the code above.
// in some react component
useEffect(() => {
getUser(userId).then((user: User) => {
setUser(user)
}).catch((reason: any) => {
setError(reason)
});
}, []);
Unfortunately in the catch “branch” the inferred type is any
, not a useful type for us here. One solution would be to tell the typescript compiler what type of the error is.
.catch((reason: ErrorResponse))
This would work if the error we could get had one type. But that is not generally true. Sometimes the error might be something that is returned from the API like for example an authorization error and sometimes it might be an error occurring due to network problems on the user side. The easy solution would perhaps be to just create some big union type with all the errors that might come our way. But that really just leads to more problems.
Having thought about how to solve this in a nicer way and remembering that I wrote a tiny little library with two useful types. I called the library picofp and gives you a Maybe
type and a Result
type inspired by Rust and Scala. The interesting type to use here is the Result
type.
A result type is a type that represent either (and in most functional programming languages it is called just that, either) a successful computation that is of type Ok
and contains the computed value, or an error of type Err
that contains an error. It also has functions on it that allows you to safely operate on the Result.
const r = new Ok('A string ');
const r = r.map((val) => val.trim())
.map((val) => val.toLowerCase())
The code above results in a Result
with a value of 'a string'
. If r
would have been an Err
it would simply just have passed the error through.
The library also allows you to approximate pattern matching with the match
function.
const r = new Ok(10);
r.match({
Ok: (val) => val,
Err: (error) => error,
})
The good thing about this is that it is typesafe! So how can we use it for the API example above?
First we’ll make all the API calling functions async
. We’ll then await
the result, that will give us a Result
back. We can then use that for (IMHO) cleaner code.
const getUser = async (userId: string): Promise<Result<APIResponse, Error>> => {
return fecth(...).then((res) => res.json)
.then((json) => new Ok(json))
.catch((reason) => new Err(reason))
}
Then when we want to use it.
useEffect(() => {
const getUserInfo = async () => {
const user = await getUser(userId);
user.match({
Ok: (user) => setUser(user),
Err: (error) => setError(error)
});
}
getUserInfo();
}, []);
The great thing here is that the error
in the Err matching branch will have the type that we want it to have. We often might want to operate a bit on the data we get with the convenience functions that are provided like map
and flatmap
.
Now astute readers will notice that we still have the problem of having an any
type luring in our software. We have just pushed it away a bit. But we have pushed it away to its proper location. Now the responsibility of correctly typing the error we can get back, which is where it should be. Not with the caller.