Modeling UI with Discriminated Union Types

I like to model my app’s state using discriminated union types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type MyState<T> = {
  type: 'pristine',
} | {
  type: 'loading',
} | {
  type: 'loaded',
  data: T,
} | {
  type: 'failed',
}

Reason is that it forces me to think about all states in the UI.

pristine means it’s never been touched. In most places this is the initial state which will change in a split second, normally upon first mounting the component which then starts a data load. But sometimes it’s different, for example, a Dropdown that lazy loads data only when clicked.

loading means data is being loaded (duh). Don’t assume data will be loaded instantly, and instead, code the UI such that it has meaningful visuals. Loading spinner, a skeleton screen etc.

loaded means we now have data. This is the “good path”.

failed means that data fetching failed. Honestly I don’t always add it, since sometimes it’s dealt with at a different layer. But you could show a message in the component body, a retry button, or just an indication that the component is incomplete.

Sometimes I also add a reloading state:

1
{ type: 'reloading', data: T }

The difference from loading is that this time, we have data. Useful for stuff like graphs and dashboards, where you don’t want to lose previous data while you are reloading.

Then I like to use switch case with exhaustiveness checks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
switch (s.type) {
  case 'pristine': {
    return <></>
  }
  case 'loading': {
    return <div>Loading</div>
  }
  case 'failed': {
    return <div>Huhh something went wrong</div>
  }
  case 'loaded': {
    const data = s.data;
    return <MyHappyPathComponent data={data} />;
  }

  default: {
    const exhaustiveCheck: never = s;
    return exhaustiveCheck;
  }
}

The trick is the use of never. It forces us to deal with any additional type when they are added (eg a new reloading type). This is a pretty useful technique when dealing with unions (eg A | B then you add A | B | C).

Source: https://basarat.gitbook.io/typescript/type-system/discriminated-unions#exhaustive-checks

We can put that into a component

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function MyComponent(s: MyState) {
  switch (s.type) {
    case 'pristine': {
      return <></>
    }
    case 'loading': {
      return <div>Loading</div>
    }
    case 'failed': {
      return <div>Huhh something went wrong</div>
    }
    case 'loaded': {
      const data = s.data;
      return <MyHappyPathComponent data={data} />;
    }

    default: {
      const exhaustiveCheck: never = s;
      return exhaustiveCheck;
    }
  }
}

Then for MyHappyPathComponent, how can one type its props? We can’t simply access like MyState["data"], since that field only exists for the type: "loaded" variant.

The solution here is to use Typescript’s Extract

1
2
3
type MyHappyComponentProps = {
  data: Extract<MyState, { type: "loaded" }>['data']
}

The way it works is as follows: Extract only the types that are assignable to {type: "loaded"}. In this case, only loaded is, which we then get only its data.

For an example (with required adaptations) see it in the typescript playground.

Downsides

As with everything in life, there’s also downsides:

  • There’s quite a bit of boilerplate, although it’s only because we are dealing with cases we would otherwise deal with implicitly or not at all.
  • It gets coupled with the state. So if you use redux, with an approach like this you end up not using selectors very often, only to get the state itself. All the further manipulations are done in the component. For example, selectMyData(s: State) => Data isn’t something you would write anymore.