How to typescript: Representing hierarchical state with tuples

September 20, 2020 in

For our latest feature in ahead, messages, the UI is in one of three major states:

type MessagingState = 
  | "IDLE" 

A couple of iterations and additions later, the mode in which there is an active conversation had two sub-states, depending whether you are writing and looking at the latest messages or are browsing older messages. Some UI parts are only really interested in whether there is currently a conversation active or not.

One could expand the state of the UI with an additional property denoting that sub-state:

  mainMode: MessagingState;
  subMode: "SYNCED" | "EXPLORING" | null;

While this certainly works, there is no quick way to consolidate into the type that the sub-state is only valid for one of the main states and not for the other.

Well, there is. The last post dealt with using discriminated unions for the state type as well. This has its own drawbacks, though - dealing with the type can become cumbersome.

So, here's another way to represent the dependency between main and sub-state, namely via tuples:

type MessagingState = 
  | ["IDLE"] 

It is now clear that only one of the main states has two further sub-states. However, checking against in which state we are can become cumbersome, as we can't just do an equality check of the state value but must rather check against the members of the tuple.

To overcome this, a helper function is introduced:

function matches<
  T extends [string] | [string,string], 
  X extends T[0], 
  Y extends T[1]>(
  value: T,
  check: X,
  check2?: Extract<T, [X, Y]>[1],
) {
  return value[0] === check && (check2 ? value[1] === check2 : true);

it accepts

  • a first value being a string-tuple with one or two members
  • A second value that needs to be of the type of the first tuple member
  • An optional third value that, if given, must correspond to the second tuple member in the context of the first member given

The way the type of the function is defined allows to "check" the checks one writes down at compile-time. That is, the function only supports calls that correspond to valid states:

const state = ["IDLE"] as MessagingState;

matches(state, "IDLE") // Compiles
matches(state, "BLA") // Doesn't Compile
matches(state, "ACTIVE_CONVERSATION") // Compiles
matches(state, "ACTIVE_CONVERSATION", "SYNCED") // Compiles
matches(state, "ACTIVE_CONVERSATION", "BLA") // Doesn't Compile
matches(state, "IDLE", "SYNCED") // Doesn't Compile

With this kind of state definition, UI code can now quickly check whether some state is given in general, or can make a stricter check whether some sub-state is currently set or not.

If you want to play around with this code, you can visit this playground.

Previous & Next

How to typescript in react: I can haz better component states!