Dealing With Invalid State in Elm

What if you can’t make it all impossible?

billperegoy
im-becoming-functional
5 min readNov 14, 2017

--

Working Around an Invalid State

For the past few weeks, I’ve been walking you through the decisions I made in creating a dart scoring application. I worked hard to build a coherent and expressive set of types and tried to relentlessly stamp out invalid states. Let’s review the types I was working with last week.

type alias Model =
{ player1 : Player
, player2 : Player
, currentTurn : PlayerID
, currentDart : Int
}
type alias Player =
{ name : String
, status15 : TargetStatus
, status16 : TargetStatus
, status17 : TargetStatus
, status18 : TargetStatus
, status19 : TargetStatus
, statusBullseye : TargetStatus
, score : Int
}
type TargetStatusValue
= Unopened Int
| Opened
| Closed

Note that the model has an entry for each player, and each player has an entry for each target type’s status.

In a dart game, there can never be a situation where both players have the same target Opened. My type system does not prevent this. I knew this when I created the type system, but I justified this by thinking it was a simple case to prevent. Here is the simple logic I needed to implement to prevent that.

I knew that any time a player opened a target, I’d have to check the equivalent target for the other player and if that player already had opened that target, I’d then mark that target closed for both players.

I imagine it hurts your head as much as it does mine to read that run-on sentence. Generally logic that is convoluted to describe will be convoluted to implement and maintain. And that was indeed the case here.

Here’s the Problem

AsI was coding up this application using TDD, I got to the point where I thought I had a working application. I moved from TDD to real user testing and within 5 minutes, I discovered a situation where I had both players with the same target in an Opened state. I had attempted to solve this problem, but had failed to capture all of the cases.

Trying to patch the code was not a pretty sight. I had structured most of my code out of functions that only consumed the part of the model they needed — generally a single player record. But in this case, I had to simultaneously update two deeply nested record values. Updating deeply nested records in Elm is never a straightforward process in any case. But doing so in two separate records was just making my code difficult to write and even more importantly, difficult to maintain. I decided there had to be a better way.

A Solution

I couldn’t come up with a way to make this state illegal and still have my model feel simple and coherent. Given this, I decided that the best bet was to take the two pieces of state that were intertwined and make sure they were near each other in my model. After some experimentation and trial and error, I decided to move away from a player-centric model and into a target centric model. Here’s what I came up with.

First, I moved the status for each target to the top level of my model.

type alias Model =
{ status15 : TargetStatus
, status16 : TargetStatus
, status17 : TargetStatus
, status18 : TargetStatus
, status19 : TargetStatus
, status20 : TargetStatus
, statusBullseye : TargetStatus
, currentTurn : PlayerId
, currentDart : Int
, score : Score
}

So, I was now going to need to store information for both players in one status value. I defined the new TargetStatus like this.

type TargetStatus
= Active PlayerTargetStatus PlayerTargetStatus
| Closed

Now I was thinking of a target status as something that applied to both players at once. A target was either active and stored data for both players or it was closed.

The PlayerTargetStatus looked similar to the previous incarnation. We just eliminated the closed status.

type PlayerTargetStatus
= Unopened Int
| Opened

Using this structure, a single TargetStatus would have states like this.

Active Unopened Unopened
Active (Opened 2) Upopened
Active (Opened 1) (Opened 2)
Closed

This seemed easy to read and kept all the data that was related near each other. This also eliminated the possibility of having a target Opened for one player and Closed for the other.

Now, this doesn’t eliminate all of the invalid possibilities. It’s still illegal to have an Opened status for both players. But given that both players’ data is stored together, the code to prevent this possibility is very localized. It touches a very small part of the model (one TargetStatus element). It’s very easy to isolate that logic and unit test it.

With this change, I now feel that even though I have one invalid state left in my model, it’s localized to the point that I can easily unit test it and feel confident that state will not be reached.

Making this change was intimidating. I knew it would touch nearly every line of code. But it turned out to be relatively straightforward. I was able to use the test cases I’d already written as a starting point. In fact, my tests got simpler as the flatter model made generating stimulus much simpler. I was able to get the application up and running again in hour or two.

Lessons Learned

I never intended to write four articles about this one relatively simple application, but it turned out to be a treasure-trove of learning on building coherent and unambiguous types and test-driven development. Here are the most recent lessons I learned from this latest refactor.

  1. If you see your code becoming ugly, stop right there and figure out why.It’s so easy to just keep pushing on until you create an unmaintainable monstrosity.
  2. Don’t be afraid to throw away code. In my experience, it’s never been too painful to make a radical change to my model and rebuild the code that uses that model. The Elm compiler holds your hand every step of the way. My future self will thank me for taking the time to restructure.
  3. Avoid deeply nested records until they really make sense. Coming from an OOP background, I’ve been trained to break things into tiny pieces and build layers of abstractions. With Elm, I find it best to start with a larger concrete and shallower model and only make it deeper when things become unwieldy.
  4. Keep looking for invalid state combinations and stamp them out when possible.
  5. Keep and related state that may have invalid combinations close to each other. If you know of invalid state, try to isolate it so that state can always be changed with a simple atomic operation on a small piece of data. This ensures that this invalid state can be thoroughly unit tested.

--

--

billperegoy
im-becoming-functional

Polyglot programmer exploring the possibilities of functional programming.