Sum Types for Remote Data in TypeScript
If you’ve ever stumbled into the Elm community, you
may have encountered the usage of the Result
type. Kris Jenkin’s
post, How Elm Slays a UI
Antipattern
provides a nice example of sum types to better define UI states.
Essentially, sum types prevent us from having to remember to check loading
,
updating
or similar flags before rendering data, as the data is no longer
IData
, and is instead wrapped inside a Success<T>
which is unioned with Loading
,
Updating<T>
, and additional states.
The Elm example outlined in Jenkin’s post can be created in TypeScript pretty easily, although with greater verboisty since sum types aren’t as easy to define.
First let’s assume we have an equivalent type to Elm’s HttpError and Response.
Now we define the TypeScript version of the RemoteData
and WebData
types.
import { HttpError } from './http'
const enum k {
NotAsked,
Loading,
Failure,
Success
}
interface NotAsked {
readonly kind: k.NotAsked
}
interface Loading {
readonly kind: k.Loading
}
interface Failure<E> {
readonly kind: k.Failure,
readonly failure: E
}
interface Success<T> {
readonly kind: k.Success,
readonly data: T
}
type RemoteData<E, T> =
| NotAsked
| Loading
| Failure<E>
| Success<T>
type WebData<T> = RemoteData<HttpError, T>
Which we can then when defining our application state.
interface IUser {
readonly id: number
readonly email: string
readonly active: boolean
}
interface IState {
readonly users: WebData<ReadonlyArray<IUser>>
}
export function render(): string {
const state: IState = {
users: { kind: k.NotAsked }
}
switch (state.users.kind) {
// With strict function types turned on, we will get a compiler error
// if we leave a case out since we define our return type as `string`.
case k.NotAsked:
return "You haven't fetch the users yet"
case k.Loading:
return "Loading..."
case k.Failure:
return `Uh oh! There was a failure: ${state.users.failure}`
case k.Success:
const emails = state.users.data.map(u => u.email)
return "Huzzah! Users fetched." + emails.join(", ")
}
}
Now our RemoteData
has the same functionality as the Elm version, but
how well does it work when we fully normalize our state?
For example, if we normalize our
state
into a structure with byId
and allIds
, then we need to rethink how we
represent WebData
.
// example normalized state
interface IState {
readonly users: {
readonly authUser: IUser['id']
readonly byId: {
readonly [key: number]: IUser
}
readonly allIds: ReadonlyArray<IUser['id']>
}
// --snip--
}
So how could we represent Loading
, NotAsked
, Failure
, and Success
without resorting to boolean
flags?
If we define byId
as WebData<{ readonly [key: number]: IUser }>
, then
whenever we fetch a single user, all the users will transition to the loading
state. No good. If we were using flags, we could just add a loading
flag to IUser
.
We could define each byId
as WebData<{ readonly [key: number]:
WebData<IUser> }>
, and do the same for authUser: WebData<IUser['id']>
.
The only issue with this approach is that when accessing plain objects {}
by key
and the value doesn’t exist, we get
undefined
. Ideally we would have a custom Map
type that provides a default if
the user doesn’t exist, something like Python’s
defaultdict
.
So with the following State
, we handle the case where we want to fetch
individual users.
interface IState {
readonly users: {
readonly authUser: WebData<IUser['id']>
readonly byId: {
readonly [key: number]: WebData<IUser>
}
readonly allIds: ReadonlyArray<IUser['id']>
}
}
// fetching a single user at a time
export function userDetailView(): string {
const state: IState = {
users: {
authUser: {kind: k.NotAsked},
byId: {},
allIds: []
}
}
// likely passed in from URL
const id = 1
const user = state.users.byId[id]
switch (user.kind) {
case k.NotAsked:
return "Haven't fetch user yet"
case k.Loading:
return "Loading user..."
case k.Failure:
return `Uh oh! There was a failure: ${user.failure}`
case k.Success: {
return "Huzzah! User fetched and found. User email: " + user.data.email
}
}
}
Now what about the case of a list view of users?
We can use the same setup as byId
.
So allIds
changes from type ReadonlyArray<IUser['id']>
to WebData<ReadonlyArray<IUser['id']>>
.
This means that when we are fetching all the users for the list view, we will not be able to retrieve the users until the data type Success<T>
.
And our list view might look something like this:
// for now we have to specify the type guard
// see https://github.com/Microsoft/TypeScript/issues/16069
const isSuccess = <T>(x: WebData<T>): x is Success<T> => x.kind === k.Success
// render a list of users
export function userList(): string {
const state: IState = {
users: {
authUser: { kind: k.NotAsked },
byId: {},
allIds: {
kind: k.NotAsked
}
}
}
switch (state.users.allIds.kind) {
case k.NotAsked:
return "You haven't fetch the users yet"
case k.Loading:
return "Loading..."
case k.Failure:
return `Uh oh! There was a failure: ${state.users.allIds.failure}`
// We will get a compiler error if we leave a case out, with strict function types turned on
case k.Success: {
const emails = state.users.allIds.data
.map(id => state.users.byId[id])
.filter(isSuccess)
.map(u => u.data.email)
.join(", ")
return "Huzzah! Users fetched. Emails: " + emails
}
}
}
And if we wanted to add an updating state for IUser
, we could just add
another case to our RemoteData
type.
const enum k {
NotAsked,
Loading,
Failure,
Success,
Updating,
}
interface NotAsked {
readonly kind: k.NotAsked
}
interface Loading {
readonly kind: k.Loading
}
interface Failure<E> {
readonly kind: k.Failure,
readonly failure: E
}
interface Success<T> {
readonly kind: k.Success,
readonly data: T
}
// we likely want data in Updating since we still want to render the user.
interface Updating<T> {
readonly kind: k.Updating,
readonly data: T
}
type RemoteData<E, T> =
| NotAsked
| Loading
| Failure<E>
| Success<T>
| Updating<T>
Great. That works. We’ve handled both the detail view and the list view and
can easily add additional states to our RemoteData
type.
If the sum types are too much of a hassle for a specific use case, then you could always use flags for those instances, and use sum types everywhere else.
By using sum types we let the compiler prevent UI errors from ever occurring.