Have you ever looked at a piece of code and thought:

I think this works, but I can‘t quite convince myself it‘s correct?

The logic is relatively straightforward.
But the state is distributed across a handful of variables that could be combined in ways that don‘t make sense.
And as soon as you start working on it, you find yourself mentally tracing through every code path to make sure none of the illegal variable combinations can happen.

Whenever this happens, one thing is part of the problem: The model of the domain was only ever implicitly there, in the head of the programmer, and not written down in code.

In this series, I want to show you how to formulate those models in code, in TypeScript, so that the code gets easier to understand for everyone not currently writing the code (colleagues, but also future you!). We‘re going to take a small but realistic application and refactor it from a hidden-model style to an explicit-model style.

We‘ll start out with a small but realistic application—a web audio player—in a hidden-model style and rework it. Along the way, you‘ll learn how to:

  • make illegal states unrepresentable using algebraic data types
  • how to push side effects to the boundary of your application
  • and how to use reducers and pure functions to make state transitions explicit and testable.

(among others)

Why an Audio Player?

Mostly to keep the mental workload small: You already have a good mental model of how an audio player should work, and whatever we‘re going to add to it, it‘s still going to be relatively simple. These properties make it a good example application for learning the techniques, and not the domain, of this tutorial.

Also, it‘s still a barebones useful thing, and not the, say, standard counter example.

Approach

We‘re starting out writing the player in ‚normal‘, hidden-model SolidJS style.

Then, we‘re going to take a look at the hidden model, make it explicit, and transform the whole codebase accordingly.

After this, I‘m going to show you avenues for improvement: When and where to use functional data structures, more precisely typed records, and so on.

Audio Player: The Hidden-Model Version

Let‘s start with the barebones component:

import { createSignal, For, Show } from 'solid-js'
export const ImperativeAudioPlayer = () => {
  return (
    <div style=>
      <h2>Imperative Audio Player</h2>

      <For each={tracks}>
        {(track) => <button onClick={() => playTrack(track)}>Play {track.title}</button>}
      </For>

      <div style=margin-top>
        <Show when={currentTrack()} fallback={<p>No track selected</p>}>
          <h3>Now: {currentTrack().title}</h3>

          <Show when={isLoading()}>
            <p>Loading...</p>
          </Show>

          <p>Time: {currentTime().toFixed(2)}s</p>

          <button onClick={togglePlay}>{isPlaying() ? 'Pause' : 'Resume'}</button>
        </Show>
      </div>
    </div>
  )
}

Add in the signals:

const [isPlaying, setIsPlaying] = createSignal(false)
const [isLoading, setIsLoading] = createSignal(false)
const [currentTrack, setCurrentTrack] = createSignal<any>(null)
const [currentTime, setCurrentTime] = createSignal(0)

The audio element:

const audio = new Audio()

The callback functions for the elements:

const playTrack = (track: any) => {
  setCurrentTrack(track)
  setIsLoading(true)
  setIsPlaying(false)

  audio.src = track.url
  audio.play().catch((err) => {
    console.error('Playback failed', err)
    setIsPlaying(false)
  })
}

const togglePlay = () => {
  if (audio.paused) {
    audio.play()
    setIsPlaying(true)
  } else {
    audio.pause()
    setIsPlaying(false)
  }
}

Now, there‘s only two things left:

  1. Initializing the audio element with the correct callbacks:
onMount(() => {
  audio.oncanplay = () => {
    setIsLoading(false)
    setIsPlaying(true)
  }

  audio.ontimeupdate = () => {
    setCurrentTime(audio.currentTime)
  }
})

And actually adding in tracks:

const tracks = [
  {
    id: '1',
    title: 'Lo-Fi Beats',
    url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
  },
  {
    id: '2',
    title: 'Synthwave',
    url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
  },
]

This should complete to something like this: SolidJS playground, Github Permalink

Making Illegal States Unrepresentable: Model

Right now, isPlaying, isLoading, currentTrack and currentTime are independent values. I think there‘s no mistake in there, but:
Every time, I (or someone else) touches the code, there‘s a risk they can‘t infer the correct model from reading the code. Since it‘s nowhere explicitly written down, it‘s easy to make a change so that the system arrives at a state like {isPlaying: true, isLoading: true, *}. (which doesn‘t make any sense and therefore should be illegal).
This is bad, so let‘s change that.

To make our model explicit in code, we need to reduce the number of legal states in our program to the number of legal states in our model.

So, which states are legal in our model?

  1. An initial state: When the song‘s not playing, and hasn‘t started loading yet. (Corresponding to {isPlaying: false, isLoading: false, currentTrack: null, currentTime: null})
  2. A Loading state: We have a track selected, but it hasn‘t loaded yet. (Corresponding to {isPlaying: false, isLoading: true, currentTrack: someTrack, currentTime: null})
  3. A Playing state: When the song is done loading, and playing. (Corresponding to {isPlaying: true, isLoading: false, currentTrack: someTrack, currentTime: someTime})
  4. A Paused state: When we‘ve paused the song. (Corresponding to {isPlaying: false, isLoading: false, currentTrack: someTrack, currentTime: someTime})

Making Illegal States Unrepresentable: Implementation

Explicit Model

The way we‘re going to implement this model is by using TypeScript‘s tagged unions. (The ‚tag‘ here being the additional ‚type‘ key in the object notation)

These are the four legal states:

export type Track = { id: string, url: string, title: string }
export type Time = number

export type Initial = { type: 'Initial' }
export type Loading = { type: 'Loading', track: Track }
export type Playing = { type: 'Playing', track: Track, currentTime: Time }
export type Paused = { type: 'Paused', track: Track, currentTime: Time }

And our whole AudioPlayer State is then a Union of those types:

export type State = Initial | Loading | Playing | Paused

Explicit Model Transitions

Let‘s start using this State model in our audio player. For this, we need to add the initial state:

const [state, setState] = createSignal<State>({ type: 'Initial' })

Then, we can visit each usage of isPlaying, isLoading, currentTrack and currentTime, and replace them with their State equivalent.

I find that easiest to refactor this from the initial state on:

  • After loading the page, we‘re in the Initial state.
  • Then, as soon as we click playTrack, we should go over to the Loading state
  • Then, we trigger the mp3 load via setting audio.src
  const playTrack = (track: Track) => {
    setState({ type: 'Loading', track })

    audio.src = track.url
  }
  • As soon as the source is loaded, oncanplay fires:
    audio.oncanplay = () =>
      setState((prev: State) => {
        if (prev.type === 'Loading') {
          return { type: 'Playing' as const, track: prev.track, currentTime: 0 }
        } else {
          return prev
        }
      })
  • This should make our audio.play() in playTrack succeeed:
  const playTrack = (track: Track) => {
    setState({ type: 'Loading', track })

    audio.src = track.url
    audio.play().catch((err) => {
      console.error('Playback failed', err)
      setState({ type: 'Paused', track, currentTime: 0 })
    })
  }
  • After which our ontimeupdate triggers regularly:
    audio.ontimeupdate = () =>
      setState((prev: State) => {
        if (prev.type === 'Playing') {
          return { ...prev, currentTime: audio.currentTime }
        } else {
          return prev
        }
      })
  • Now, we should be able to togglePlay:
  const togglePlay = () =>
    setState((prev: State) => {
      if (prev.type === 'Paused') {
        audio.play()
        return { ...prev, type: 'Playing' }
      } else if (prev.type === 'Playing') {
        audio.pause()
        return { ...prev, type: 'Paused' }
      } else {
        return prev
      }
    })

And with that, we have our state change logic.

TypeScript Type Narrowing in SolidJS

We also need to update all the usages of our state in the JSX, and there, we encounter a problem:

When doing something like <Show when={state().type === 'Loading'}> ... </Show> in SolidJS, TypeScript can‘t correctly narrow the type of state() to the Loading state.

(And we should use the Show component for correctly working reactivity in SolidJS, not just some {state().type === 'Loading' && ...})

To circumvent this, we need to use the callback children version Show provides, and then provide narrowed type annotations. This looks like this:

  const trackState = createMemo(() => {
    if (state().type === 'Loading' || state().type === 'Paused' || state().type === 'Playing') {
      return state() as Loading | Playing | Paused
    } else {
      return undefined
    }
  })
        <Show when={trackState()} fallback={<p>No track selected</p>}>
          {(ts) => ( // <- the narrowed state gets used here. 
            <>
              <h3>Now: {ts().track.title}</h3>

              <Show when={state().type !== 'Loading'} fallback={<p>Loading...</p>}>
                <p>Time: {(state() as Playing | Paused).currentTime.toFixed(2)}s</p>

                <button class="p-2 bg-zinc-200 m-2 cursor-pointer" onClick={togglePlay}>
                  {state().type === 'Playing' ? 'Pause' : 'Resume'}
                </button>
              </Show>
            </>
          )}
        </Show>

Our whole JSX now looks like this:

  return (
    <div style=>
      <h2>Audio Player: Illegal States Are Unrepresentable</h2>
      {JSON.stringify(state())}

      <For each={tracks}>
        {(track) => (
          <button class="p-2 bg-zinc-200 m-2 cursor-pointer" onClick={() => playTrack(track)}>
            Play {track.title}
          </button>
        )}
      </For>

      <div style=margin-top>
        <Show when={trackState()} fallback={<p>No track selected</p>}>
          {(ts) => (
            <>
              <h3>Now: {ts().track.title}</h3>

              <Show when={ts().type !== 'Loading'} fallback={<p>Loading...</p>}>
                <p>Time: {(ts() as Playing | Paused).currentTime.toFixed(2)}s</p>

                <button class="p-2 bg-zinc-200 m-2 cursor-pointer" onClick={togglePlay}>
                  {ts().type === 'Playing' ? 'Pause' : 'Resume'}
                </button>
              </Show>
            </>
          )}
        </Show>
      </div>
    </div>
  )

Full code for this version here: SolidJS playground, Github

With this, we have achieved our goal: The model is explicitly written down in code, and it‘s not possible to represent an illegal state anymore.

What is possible (and easy) right now, however, is making a mistake in the state transitions (since they‘re distributed over the whole codebase).

In the next part, we‘re going to use the reducer pattern and pull our state transitions together into one function called reduce:

export const reduce = (oldState: State, action: Action): State => {
  // ...
}

See you there!