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.
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.
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:
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.