Typesafe handling of remote APIs with Flow
08 Jul 2018
You built yourself a small typesafe program... as everything type-checks and Flow is patting you on the back, you are growing the program.
One day, you decide you need to talk to a remote server — a lot of programs these days do that.
But it seems like your perfect little castle is going to be filled with assassins and what not... I mean, you can't rely on the server upholding its end of the contract.
How do you keep the type safety that you fought so hard for, while when working with an API?
Let's take a super-simple example: we want to get the current user from the API.
The response object looks something like this: { "id": 1, "name": "Monaca", "avatarUrl": null }
.
Step 1. Make a type.
That object is a User
, and we should give it a type it deserves:
type User = {
id: number,
name: string,
avatarUrl: ?string,
};
Just enumerate all of its properties and their value types.
As a reminder, here's a list of primitive types Flow supports.
Step 2. What will this remote function be like?
Let's focus on the shape instead of implementation for now.
The result of an asynchronous action is often signaled using a Promise
that will (eventually) contain the response from the server.
The shape of that function (aka, its type signature) could look as follows:
() => Promise<User>
If you're thinking, "Wait a minute, what's this <>
business?" here's a short summary:
A Promise
is a generic type, much like Array
.
What this means is, you don't just have an Array
of anything or a Promise
of anything — you have an Array
of number
s (Array<number>
), or, in this case, a Promise
of a User
(Promise<User>
).
We can define the function like this:
function getCurrentUser(): Promise<User> {
return fetch('/me').then(x => x.json());
}
The return value of fetch
is Promise<any>
, and getCurrentUser
casts it to Promise<User>
... which is in the Danger Zone™.
At this point, if you trust your API enough to always return a well-formed response, you might as well stop there.
However, for most cases, you should not rely on the server.
So read on.
Step 3.
any -> User
We are not settling with any -> User
conversion by merely casting... after all, this can lead to terrible bugs... because we are not sure yet if what we got is User
indeed.
What can we even do?
We can introduce another function, parseUser(user: any): User
, which accept server response (as any
).
It will either return a User
object if all is well, or it will throw an error if server response is of wrong shape.
We will use it like this:
function getCurrentUser(): Promise<User> {
return fetch('/me').then(x => x.json()).then(parseUser);
}
The simplest implementation of the parseUser
function would be:
function parseUser(user: any): User {
if (
typeof user.id === 'number' &&
typeof user.name === 'string' &&
(user.avatarUrl === null || typeof user.avatarUrl === 'string'
) {
return user;
} else {
throw new Error('Could not parse User');
}
}
Yes. We are checking in runtime. And no, there's nothing wrong with it — because we are dealing with a value the type of which is only known at the runtime. There is no way around runtime checks.
And this could be just enough. You can go and do that in your apps.
The code for the parseUser
function might feel somewhat manual.
And it will only grow harder to read as we add more fields, or introduce parsers for other models.
That's something we can fix though.
Step 4. Composability.
.
.
.
.
This section is not ready yet.
Leave your email in the subscription box below 👇 to receive it once it's finished.