loading...

State machine advent: A safer way to type events and state (11/24)

codingdive profile image Mikey Stengel Updated on ・2 min read

I've worked quite a lot with Ember.js in the past and one of the things I really disliked is how often you have to use strings to perform simple tasks like getting the value of a property. If you like string-based APIs, I want to take a moment to explain why they are evil and then present a better, safer way to define events and state.

Throughout the series, we have seen many strings being used. Given a state definition like this

interface lightSwitchStateSchema {
  states: {
    inactive: {};
    active: {};
  };
}

We used to determine the current state of the invoked machine using a string:

state.matches('active')

While TypeScript got our back and will rightfully scream at us when we mistype or rename the state node in either the interface or the machine definition, we won't get any type errors for passing an incorrect string to state.matches. In other words, strings are a problem because they can't be statically typed and ultimately make it harder to refactor our code.

A better way to write events and state nodes is to define them inside an enum (or a plain object if you are using JavaScript). This does make our code a bit more verbose but I believe the safety benefits outweigh the cost.

enum LIGHT_SWITCH {
  "INACTIVE" = "inactive",
  "ACTIVE" = "active",
}

enum LIGHT_SWITCH_EVENT {
  "TOGGLE" = "TOGGLE",
}

interface LightSwitchStateSchema {
  states: {
    [LIGHT_SWITCH.INACTIVE]: {};
    [LIGHT_SWITCH.ACTIVE]: {};
  };
}

type LightSwitchEvent = { type: LIGHT_SWITCH_EVENT.TOGGLE };

const lightSwitchMachine = Machine<any, LightSwitchStateSchema, LightSwitchEvent>({
  id: 'lightSwitch',
  initial: LIGHT_SWITCH.INACTIVE,
  states: {
    [LIGHT_SWITCH.INACTIVE]: {
      on: {
        [LIGHT_SWITCH_EVENT.TOGGLE]: LIGHT_SWITCH.ACTIVE
      }
    },
    [LIGHT_SWITCH.ACTIVE]: {
      on: {
        [LIGHT_SWITCH_EVENT.TOGGLE]: LIGHT_SWITCH.INACTIVE
      }
    },
  }
});

If you want to send an event from a component or match the state, you can simply reuse the enums.

<Switch onChange={() => send(LIGHT_SWITCH_EVENT.TOGGLE)} checked={state.matches(LIGHT_SWITCH.ACTIVE)} />

In order to compare the machine definition from above with the one before the refactoring, take a look at the blog post from a couple of days ago. We have replaced all the strings and object keys with our string enums and improved our type safety in the process. 🎉

Decide for yourself if you think the tradeoff of having an easier time refactoring code and preventing typos is worth the expense of having more boilerplate. When I wrote my first state machines, I didn't think I would need to define all states and events with enums. Eventually, the refactoring experience became too frustrating to deal with. I then started to define all my events and states with string enums and wished I had done it sooner.
That being said, while I don't mind the additional boilerplate any more, my code did become a bit harder to read since making the change. If you are aware of any VSCode extension that could temporarily replace [LIGHT_SWITCH.ACTIVE] with the string value (active) with the click of a button, I'd love to know about it.

About this series

Throughout the first 24 days of December, I'll publish a small blog post each day teaching you about the ins and outs of state machines and statecharts.

The first couple of days will be spent on the fundamentals before we'll progress to more advanced concepts.

Discussion

pic
Editor guide
Collapse
fredyc profile image
Daniel K.

I have been using enums a lot in the past (not with xstate), but then I switched over to string unions and it feels much better. I wonder if it could somehow be utilized here as well.

export type TLightSwitchState = "inactive" | "active"

export type TLightSwitchEvent = "TOGGLE"

I am not that well versed with TS yet to figure out the next steps. It would be lovely if we could do something like and it would assemble the correct final type for a machine.

interface LightSwitchStateSchema {
  states: States<TLightSwitchState>
}
Collapse
codingdive profile image
Mikey Stengel Author

It's a cool idea but keep in mind that you also have to use the values to check the current state or to send events. How would you get the light switch event value when doing something like this?

<Switch onChange={() => void send(LIGHT_SWITCH_EVENT.TOGGLE)} />

If you are interested in reducing boilerplate, check out this experimental builder for XState github.com/iliasbhal/xstate-builder and submit machines for the author to look at in this issue.

Collapse
fredyc profile image
Daniel K.

The interesting project although it might end up with quite complicated API to cover all cases that XState supports.

As for unions, usually, I do the following when it's hard to have correctly typed one end. It will correctly type-check like that without the need for verbose enum.

export const makeEvent = (name: TLightSwitchEvent) => name

// in the component

send(makeEvent('TOGGLE'))

Not sure why send is not currently typed in this regard. It should ideally be able to infer from the machine itself.

And matches are obvious bigger nut to crack with nesting. I am not sure if TS allows for it dynamically.

Collapse
codingdive profile image
Mikey Stengel Author

Another way is to create a plain JavaScript object from an array using .reduce() which allows you to define each string only once.

const LIGHT_SWITCH_EVENT = [
  'TOGGLE',
  'SOME_OTHER_EVENT',
].reduce((obj, item) => {
  obj[item] = item;
  return obj;
}, {});

// then use it like this: LIGHT_SWITCH_EVENT.TOGGLE

I'll stick to enums as I think they are much easier to read.

Collapse
oroneki profile image
Oroneki

really good!