MM

Data that grows together, goes together

Stateful systems that get into inconsistent states are a frequent source of bugs. Making conceptually interdependent data also programmatically interdependent is one way of addressing the root of the problem.

When a bug breaks production, it’s good to ask: “Why didn’t a test catch that?”.

An even better question is “Why did the code build at all?”, but it isn’t asked nearly as often. Why?

Perhaps because the bug was of the kind that could only be caught at runtime, so it did need a test. Fair enough. Perhaps the bug could be caught at compile time, we just didn’t know how. That’s easy to fix. Perhaps we adopted a hard and fast “test all the things” rule, eager to consider the matter of code reliability closed and move on to less taxing areas. That’s harder to fix.

Help the compiler to help you #

Preventing bugs costs less than catching them, and one class of bugs amenable to prevention is inconsistent states, e.g. a fetchError and an isLoading variables ending up set at the same time, the rest of the code scrambling through the paradox.

It’s a programmer bug before a programming one: give your coworkers a piece of code where that can happen, and you’re giving them work (tracking data relations in their heads to make sure they stay consistent) that a machine is able to do. Giving to humans work suitable for the machine is anti-programming.

Such bugs can be often prevented by encoding constraints (“it’s not possible for fetchError and isLoading to be set at the same time”) in a form that development tools can understand.

Notably: types.

A before-and-after demonstration #

Below is a React hook that exposes the browser’s Screen Wake Lock API. A wake lock tells the device not to blank the screen even if the user isn’t interacting, e.g. on pages with long texts.

The API is deceptively simple: ask the browser for the lock, get the response. But:

  1. the browser may or may not have the capability;
  2. the browser doesn’t respond synchronously;
  3. you get a “sentinel” object that you can later used to release the lock; the releasing is also asynchronous, so you can’t just set the sentinel to null to signify “lock is inactive”: if you null it before release, other code can’t know if the lock is really inactive or being released; if you null it after release, other code can’t know if the lock is really active or being released.

We want to use the hook like this:

const wakeLock = useWakeLock()
wakeLock.setActive(true)

Here’s a traditional “flag fest” implementation:

import { useState } from 'react'

export const useWakeLock = () => {
const [sentinel, setSentinel] = useState<WakeLockSentinel | null>(null)
const [isActive, setIsActive] = useState(false)
const [error, setError] = useState<null | Error>(null)

const enable = async () => {
try {
const sentinel = await navigator.wakeLock.request('screen')
setSentinel(sentinel)
setIsActive(false)
setError(null)
} catch (err) {
setError(err)
}
}

const disable = async () => {
if (!isActive) return
if (sentinel === null) return
sentinel.release()
setSentinel(null)
setIsActive(false)
setError(null)
}

const setActive = async (wantActive: boolean) => {
if (!('wakeLock' in navigator)) return
if (wantActive) {
enable()
} else {
disable()
}
}

return { setActive, error }
}

What happens when you inadvertently put the system in an inconsistent state?

        const sentinel = await navigator.wakeLock.request('screen')
setSentinel(sentinel)
- setIsActive(true)
+ setIsActive(false)
setError(null)

The TypeScript compiler:

…Silence…

Here’s an implementation where you tell the compiler that interdependent data is, well, interdependent:

import { useState } from 'react'

export const useWakeLock = () => {
const [state, setState] = useState<
| { type: 'inactive' }
| { type: 'requesting' }
| { type: 'active'; sentinel: WakeLockSentinel }
| { type: 'error'; error: Error }
>
({ type: 'inactive' })

const enable = async () => {
if (state.type === 'inactive') {
setState({ type: 'requesting' })
try {
const sentinel = await navigator.wakeLock.request('screen')
setState({ type: 'active', sentinel })
} catch (error) {
setState({ type: 'error', error })
}
}
}

const disable = async () => {
if (state.type === 'active') {
state.sentinel.release()
setState({ type: 'inactive' })
}
}

const setActive = (wantActive: boolean) => {
if (!('wakeLock' in navigator)) return
if (wantActive) {
enable()
} else {
disable()
}
}

const error = state.type === 'error' ? state.error : null
return { setActive, error }
}

What happens when the inconsistent state tries to sneak in?

          const sentinel = await navigator.wakeLock.request('screen')
- setState({ type: 'active', sentinel })
+ setState({ type: 'inactive', sentinel })

The TypeScript compiler:

src/hooks/wake-lock.ts:16:20 - error TS2322: Type '"inactive"' is not assignable to type '"active"'.

16 setState({ type: 'inactive', sentinel })
~~~~

[6:22:21 PM] Found 1 error. Watching for file changes.

The compiler detects that something is wrong: there is no legal scenario where the state is inactive and the sentinel is set.

(For simplicity, neither implementation handles the case where activation is requested while the lock is releasing, or deactivation is requested while the lock is activating, but it’s easy to see which one is better equipped to deal with it without complexity exploding.)

This strategy has been called “making illegal states unrepresentable”. I prefer to steal a motto from cooking, where combining seasonal ingredients into something delicious is equal parts description and prescription:

What grows together, goes together.

Further reading #

  • Compilers and assistants. Elm’s take on compilers is no less interesting than its influential UI architecture.
  • XState. Explicit state machines are the next step to rein in complexity. In fact, the second example above is just a less implicit state machine than the first. XState is an excellent framework for state machines in JavaScript, though one with a steep learning curve.