Today I want to talk about an interesting (though not new) idea for representing remote resources. In fact, any resource that takes some time to retrieve. But most often we talk about network resources. At the same time, we’re going to do a bit of TypeScript gymnastics.
Data type for side effects
The state of a remote resource can be represented in four ways:
Idle
- we haven’t even requested the resource yetLoading
- we have requested the resource, but the operation is not yet completeReady<D>
- we have the dataError<E>
- something has gone wrong
So, using a discriminated union feature of TypeScript, we can create a type for this situation:
RemoteData<D, E>
Now we need some way(s) to construct a value of this type. Each of the variants we listed above is a constructor. Here’s how we can construct them:
const Idle = () => ({ _kind: 'Idle' as const });
const Loading = () => ({ _kind: 'Loading' as const });
const Ready = <D, >(data: D) => ({ _kind: 'Ready' as const, data });
const Error = <E, >(error: E) => ({ _kind: 'Error' as const, error });
Those are just simple functions that return simple objects. Please note as const
that help to narrow the type from string
to a corresponding literal.
Using a ReturnType
utility we can reuse those constructors the following way:
type RemoteData<D, E> =
| ReturnType<typeof Idle>
| ReturnType<typeof Loading>
| ReturnType<typeof Ready<D>>
| ReturnType<typeof Error<E>>
Therefore, the _kind
field acts as a discriminator for this union type.
Okay, now we can use this type in other function. For example,
function renderRemoteString(data: RemoteData<string, SomeError>) {
// ...
}
At this point, we need to talk about a mental model for Sum types (or discriminated unions). All these constructors return a value of type RemoteData<D, E>
. In other words, the value of type RemoteData<D, E>
can be constructed in 4 ways. Therefore, within the renderRemoteString
function, we don’t really know which constructor has been used. So we need to deal with all 4:
function renderRemoteString(data: RemoteData<string, SomeError>) {
switch (data._kind) {
case 'Idle': return //...
case 'Loading': return //...
case 'Ready': return //...
case 'Error': return //...
}
}
However, TypeScript doesn’t force us to handle all cases in this switch
. Other languages do though. Let’s have a look. This is Rescript:
type remoteData<'d, 'e> =
| Idle
| Loading
| Ready('d)
| Error('e)
let render = (data: remoteData<'d, 'e>) =>
switch data {
| Idle => "idle"
| Loading => "Loading"
| Ready(_data) => "Ready"
| Error(_err) => "Error"
}
if we “forget” about, for example, the Error
case, the compiler will let us know:
You forgot to handle a possible case here, for example:
| Error(_)
Here’s Elm:
type RemoteData d e
= Idle
| Loading
| Ready d
| Error e
renderRemoteData : RemoteData String String -> String
renderRemoteData data =
case data of
Idle -> "Idle"
Loading -> "Loading"
Ready(_) -> "Ready"
Error(_) -> "Error"
If we forget, say, Loading
variant, we get one of those famous error messages:
This `case` does not have branches for all possibilities:
11|> case data of
12|> Idle -> "Idle"
13|> -- Loading -> "Loading"
14|> Ready(_) -> "Ready"
15|> Error(_) -> "Error"
Missing possibilities include:
Loading
I would have to crash if I saw one of those. Add branches for them!
But we can trick TypeScript to perform the exhaustive check by introducing an impossible state:
switch (state._kind) {
case 'Idle': return // ...
case 'Loading': return // ...
case 'Ready': return // ...
case 'Error': return // ...
default:
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
Although the error message is not as fancy as with other languages,
Type '{ _kind: "Error"; error: E; }' is not assignable to type 'never'.
… it does the job.
Folding
I think there’s an even better way to handle our data type and help our users. What if we could reduce (or fold) the whole situation of 4 constructors and the data they carry to a single value? Here’s an idea:
function fold<D, E, R>(state: RemoteData<D, E>): R {
// magic
}
But that’s not all. Our users (including ourselves) will need to specify handlers for all of these options:
const data = Loading() // or Idle, or maybe Ready... who knows...
fold(data, {
Idle: () => 'idle',
Loading: () => 'loading',
Error: (_) => 'error',
Ready: (_) => `ready`,
})
Ah-ha! Our fold
function should take a second argument:
function fold<D, E, R>(
state: RemoteData<D, E>
matcher // Magic moves here
): R {
// do the folding...
}
Let’s define a type for our matcher
argument:
// First, let's collect all the _kind literals
type Kinds<A, B> = RemoteData<A, B>['_kind'];
// This helper type pick one variant by _kind field
type PickVariant<A, B, K extends Kinds<A, B>>
= Extract<RemoteData<A, B>, { _kind: K }>;
// Finally, we can construct an object
// that has a function for each literal in _kind
type Matchers<D, E, R> = {
[K in Kinds<A, B>]: (
// here I'll just omit _kind field from function's argument
args: Omit<PickVariant<A, B, K>, "_kind">
) => R
};
Now we can finish our fold
implementation like this:
function fold<D, E, R>(
state: RemoteData<D, E>,
matcher: Matchers<D, E, R>
): R {
switch (state._kind) {
case 'Idle': return matcher.Idle({});
case 'Loading': return matcher.Loading({});
case 'Ready': return matcher.Ready({ data: state.data });
case 'Error': return matcher.Error({ error: state.error });
default:
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
All four? Every time?
A common complaint I get about this approach is that people don’t want to deal with all the variants all the time. In fact, sometimes we just want to deal with a single case. In this case, due to the lack of pattern matching in TypeScript, we have to get creative. You see, in other languages you can specify a single branch for all the cases you don’t care about. For example, in Elm:
renderRemoteData : RemoteData String String -> String
renderRemoteData data =
case data of
Idle -> "Idle"
_ -> "whatever..."
main =
Html.text <| renderRemoteData <| Loading
This will render whatever...
on the screen.
We can achieve a similar result in TypeScript by adding an additional type:
type MatcherWithDefault<D, E, R> =
Partial<Matchers<D, E, R>> & { _: () => R }
This type will be used for the matchers
argument.
function fold<D, E, R>(
state: RemoteData<D, E>,
matcher: Matchers<D, E, R> | MatcherWithDefault<D, E, R>
): R
And this is how we would handle a variable now:
case 'Idle': return matcher.Idle
? matcher.Idle({})
: (matcher as MatcherWithDefault<D, E, R>)._();
Here’s the whole function:
function fold<D, E, R>(
state: RemoteData<D, E>,
matcher: Matchers<D, E, R> | MatcherWithDefault<D, E, R>
): R {
switch (state._kind) {
case 'Idle': return matcher.Idle
? matcher.Idle({})
: (matcher as MatcherWithDefault<D, E, R>)._();
case 'Loading': return matcher.Loading
? matcher.Loading({})
: (matcher as MatcherWithDefault<D, E, R>)._();
case 'Ready': return matcher.Ready
? matcher.Ready({ data: state.data })
: (matcher as MatcherWithDefault<D, E, R>)._();
case 'Error': return matcher.Error ?
matcher.Error({ error: state.error })
: (matcher as MatcherWithDefault<D, E, R>)._();
default:
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
Now, finally, our users can chose if they want to handle all the cases, or provide a “catch all” branch:
fold(Loading(), {
Loading: () => 'loading',
_: () => 'whatever...'
})
Here’s a link to a TypeScript Playground with the code