Type Driven Design in Elm

Avoiding Primitive Obsession

billperegoy
im-becoming-functional
9 min readOct 24, 2017

--

Starting With More Expressive Types

I think for those of us coming from the world on dynamically-typed languages, the transition to using more complex types comes slowly. My general pattern is to start a new application with pretty traditional data structures consisting of lists of records. These records are usually just a collection of primitives.

In this case, I’m using the type system to ensure that I’m putting the right fields in the records. That’s a big improvement over an untyped language, but there are ways to do even better. Each of these records contains a number of primitive types, most of which can take on an infinite number of values. Many of the combinations of primitive values may not be possible in your actual working application.

As I’ve become more comfortable with the Elm type system, I now attempt to define my data model using mostly Union types. In this way, I define only the types that are possible. Eliminating these impossible states results in a program that is not only more expressive, but also has fewer possibilities of failing because these impossible states cannot be reached at runtime.

Type Driven Development

When I develop in a type-driven fashion, I tend to spend a lot of up-front time understanding and refining the types I need to represent the data model in a clear and unambiguous way. I normally iterate through creating the data model several times, looking for code smells and refining as needed. I look for these general smells.

  1. Overuse of Primitive Types
    Using raw strings and numbers when you could create a more expressive and narrow data type is a common smell. For instance representing an employee type as a string allows an infinite number of possibilities (many which are invalid). Alternatively, creating a union type that allows only valid values (i.e. Hourly, Exempt, Manager, etc.) is both more expressive and creates fewer possibilities for error.
  2. Ability to Represent Invalid States
    There is some overlap with the above here, but I think it’s worth calling out separately. If you find yourself with a number of primitives in a record and certain combinations really should never occur, this is another sign that you should be thinking about a more expressive union type. I wrote a more detail article about this several months ago.
  3. Overuse of Maybe Types
    It’s great that Elm no nil or null data type. This avoids so many runtime errors. However, look for situations where you are just replacing a null with a Maybe type. If you find yourself with many Maybe.withDefault or Maybe.map constructs throughout your code, it could be a sign that a higher level union type is a better solution.

As I work through my types, I look for examples of these smells. each time I find one, I work to refine my types to get rid of that issue. Usually this involves creating a new more expressive union type. I find that this is a very iterative process. Its important to take your time and work through the types before you start to write business logic. I find that it’s natural to resist major type changes once I start writing code. So, I spend a lot of time writing my types and using the Elm compiler to make sure the types are coherent. I find that the extra time spent up front results in a better application in the end.

An Example — Scoring a Darts Game

Where I work, my co-workers love playing darts. We’ve been talking about building a dart scoring application as a way to learn new technologies. I thought this would be a good chance to use Elm. The rules for cricket are abstract enough that I thought the Elm type system would work well. Knowing very little about the rules for cricket, I took a quick dive into the rules. I won’t go into these in detail, but I’ll point out several key aspects.

Dart Board
  • A dart board consists of 20 numbered segments, but in cricket, scoring only matters in segments numbered 15–20 or the inner or outer bullseye. Any other position can be considered a miss as it has no impact on scoring.
  • Each segment can be scored as a single, double or triple. The outer ring represents the double area. The smaller ring near the middle is the triple area. Any other portion of the segment is considered a single.
  • Players must accumulate three points in each segment in order to open that segment. They can get those points through any combination of singles, doubles and triples. Once a segment is open, it remains open until the second player accumulates their three points on that segment. After both players have done this, the segment is considered closed.

Let’s take a look at the type definitions I’ve created to represent these possibilities.

Modeling the Targets

I first decided to define a type, Target which represents all the segments that are involved in scoring. I decided to do this instead of representing the segments with integers. Doing this allowed me to only represent relevant and legal vales.

type Target
= Twenty
| Nineteen
| Eighteen
| Seventeen
| Sixteen
| Fifteen
| Bullseye

Modeling Target Status

For each target, I needed to be able to represent it’s status. A target can be opened, closed or unopened. Also in the case of unopened, it can also have 0–2 points accumulated towards the opening of that target. I created this type to represent these possibilities.

type TargetStatusValue
= Unopened Int
| Opened
| Closed

At one point, I considered making the parameter on Unopened be something other than an integer. Given that there are only three possibilities here (0–2), I don’t completely like making this an unbounded integer. However, I will also be doing math using this value so it seemed to make more sense to use a primitive value here. Otherwise, I’d need to unwrap and convert this each time I needed to perform a computation. I may reconsider this solution as I develop the application.

Modeling a Player

For each player, we need to keep track of the status of each scoring target , their score and which dart they are currently on (players get three darts per round).

I created a record that combined a Target and TargetStatus and added some primitive fields for the score and current dart. Note that I’m using a Maybe value for the current dart because the dart number only applies when it is that users turn. This is a bit of a code smell and it’s something I may reconsider later.

type alias TargetStatus =
{ target : Target
, status : TargetStatusValue
}

type alias Player =
{ name : String
, status : List TargetStatus
, score : Int
, currentDart : Maybe Int
}

Creating the Top Level Model

Finally, at the top-level we need a way to store status for each of the two players as well as which player is currently throwing.

type PlayerID
= Player1
| Player2

type alias Model =
{ player1 : Player
, player2 : Player
, currentTurn : PlayerID
}

With this in place, we next need to create an initial model.

Creating the Initial Model

First, we create an initial list of target/status pairs that will represent the initial state for each player. With no darts yet thrown, this is straightforward.

initStatus : List TargetStatus
initStatus =
[ { target = Fifteen
, status = UnOpened 0
}
, { target = Sixteen
, status = UnOpened 0
}
, { target = Seventeen
, status = UnOpened 0
}
, { target = Eighteen
, status = UnOpened 0
}
, { target = Nineteen
, status = UnOpened 0
}
, { target = Twenty
, status = UnOpened 0
}
, { target = Bullseye
, status = UnOpened 0
}
]

Next we create an initial state for each of the two players. Note that we assume that player 1 is active first.

initPlayer1 : Player
initPlayer1 =
{ name = "Player 1"
, status = initStatus
, score = 0
, currentDart = Just 1
}
initPlayer2 : Player
initPlayer2 =
{ name = "Player 2"
, status = initStatus
, score = 0
, currentDart = Nothing
}

Looking at this state, the currentDart smell becomes even more clear. Note that the only valid state is one player having a Nothing status and the other having a Just num status. Through a coding error, we could get into an invalid state. I definitely want to go back and modify the model to remove that potentially invalid state combination.

Finally, let’s model the initial state for the top-level model.

initModel : Model
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
}

This is pretty straightforward. But note that we now have a further possibility for an illegal state. Given that we store the active player in the model and the current dart inside each player, there are more possibilities for invalid states. What if we have currentTurn set to Player1, yet player 1 has a currentDart of Nothing? That’s an invalid state, but the compiler will not be able to catch it.

Let’s next refactor to get rid of this possibility.

Refactoring to Avoid Invalid States

The refactor is actually quite simple here. Storing the current dart with each player makes no sense because only one player is ever active at any time. So we can store that information at the top-level alongside the current turn.

type alias Player =
{ name : String
, status : List TargetStatus
, score : Int
}
type alias Model =
{ player1 : Player
, player2 : Player
, currentTurn : PlayerID
, currentDart : Int
}

Note that this refactor got rid of both that duplication inside the Player model but also eliminated the need to a Maybe value inside each player. This seems pretty obvious now, but it may take you several passes through the model before all the smells are ironed out.

Of course the initial model will need to be updated accordingly to match these model changes.

initPlayer1 : Player
initPlayer1 =
{ name = "Player 1"
, status = initStatus
, score = 0
}
initPlayer2 : Player
initPlayer2 =
{ name = "Player 2"
, status = initStatus
, score = 0
}
initModel : Model
initModel =
{ player1 = initPlayer1
, player2 = initPlayer2
, currentTurn = Player1
, currentDart = 1
}

Now that we have the model feeling pretty solid, we will take a look at modeling the message types.

Creating Message Types

In order to create message types, we need to take the time to envision the user interface for our application. In the case of this application, I envision a user clicking a button to represent the result of each throw. This click will trigger logic that figures out any opening or closing of targets as well as updating the score. I thought it would make sense to have an action for each target with these actions having parameters for the magnitude of the hit as well as which player made the throw. This resulted in these Msg types and associated other types.

type Magnitude
= Single
| Double
| Triple
type BullseyeMagnitude
= Inner
| Outer
type Msg
= HitFifteen Magnitude PlayerID
| Hitixteen Magnitude PlayerID
| HitSeventeen Magnitude PlayerID
| HitEightteen Magnitude PlayerID
| HitNineteen Magnitude PlayerID
| HitTwenty Magnitude PlayerID
| HitBullseye BullseyeMagnitude PlayerID
| Miss

In my mind, this is a good sign that I’m on a good path. The message types really represent the API of the application interface. In this case, I think it’s fairly clear. I feel like an outsider with a little knowledge of the rules of the game could look at this and get a clear idea of the possibilities of this application.

Summarizing

Note that at this point in the development process, I’ve written no business logic. I took a look at the needs of the program and iterated as I found and corrected smells. At this point, I feel confident that I can write business logic that results in clean, expressive code with less possibility for ambiguous or invalid state. The end result of this process should be an application that is more readable, more reliable and easier to change.

I’m sure that there are also alternative modeling techniques for this problem that are just as valid. I think the key is to start down a path, remove design smells and proceed to implementation when it feels clean.

I plan to keep developing this application. As I work in the business logic, I’m hoping to be able to report further lessons in future posts.

--

--

billperegoy
im-becoming-functional

Polyglot programmer exploring the possibilities of functional programming.