I recently found what I thought was a neat trick to simplify an update function, in an app that had a lot of update function. The trick involved creating a new type similar but different to the Model type, a strategy I'm so fond of I co-authored two previous blog posts about it, A type for views and Keeping decoders simple. So after complicated view and decoding logic, this time we'll try our hand at complicated update logic.
Afterwards I'd like to share where my enthusiasm for creating similar types is coming from. Sneak preview: it's conversion functions. Turns out I love conversion functions.
An example application
Suppose we're building an app to support humanity's effort to populate the solar system1. It should keep track of the populations of all the planets in the solar system, as well as any ships in orbit. Here's a model for that application:
type alias Model =
Body
type Body
= Body
{ name : Name
, population : Int
, moons : List Body
}
type alias Name =
String
solarSystem : Model
solarSystem =
Body
{ name = "Sun"
, population = 0
, moons =
[ Body
{ name = "Earth"
, population = 7700000000
, moons =
[ Body
{ name = "Moon"
, population = 0
, moons = []
}
, Body
{ name = "Worf's starship"
, population = 1
, moons = []
}
]
}
, Body
{ name = "Mars"
, population = 0
, moons = []
}
]
}
As you see we don't have a complete solar system here, but it's enough to illustrate the two operations the app needs to support:
- We want to move ships from one planet to another.
- We want to adjust the populations of these planets.
Here's a Msg
type for that:
type Msg
= SetPopulation Name Int
| Move { body : Name, destinationOrbit : Name }
So far so good. Now we get to implement the update function and this is where things become unpleasant. To handle the SetPopulation
message, we will need to search through the entire solar system to find the planet we need to update. Handling Move
is going to be even harder: Consider what steps we need to take to move Worf to Mars:
- First we need to find Worf somewhere in the solar system and store him in a variable.
- Then we need to find the body Worf is orbiting, and remove Worf as a moon of that body.
- And last we need to find the body Worf is moving to, and insert the copy of Worf we stored as a new moon of that body.
Oh, one last thing, should it turn out that destination body doesn't exist, we should cancel the entire operation. Otherwise Worf would disappear from the solar system entirely.
A simple update
function
When we break it down like that, it sounds like this logic will get pretty complicated. So before committing to this approach let's try to consider different solar system types. Does one exist that would make handling the messages described above simple? Yes, there is! Check out the following type for one possible approach:
type alias FlatModel =
Dict Name FlatBody
type alias FlatBody =
{ orbiting : Maybe Name
, population : Int
}
To proof that updating this type really is easier, here's the implementation of a setPopulationOf
and moveBodyTo
function:
setPopulationOf : Name -> Int -> FlatModel -> FlatModel
setPopulationOf name newPopulation flatModel =
Dict.update
name
(Maybe.map (setPopulation newPopulation))
flatModel
setPopulation : Int -> FlatBody -> FlatBody
setPopulation newPopulation flatBody =
{ flatBody | population = newPopulation }
moveBodyTo : Name -> Name -> FlatModel -> FlatModel
moveBodyTo movingBody destinationOrbit flatModel =
Dict.update
movingBody
(Maybe.map (moveBody destinationOrbit))
flatModel
moveBody : Name -> FlatBody -> FlatBody
moveBody destinationOrbit flatBody =
{ flatBody | orbiting = Just destinationOrbit }
As you can see we don't require three-step-plans to move Worf to another planet. Both setPopulationOf
and moveBodyTo
update a single value in a dictionary -- that's it! Given this triump of FlatModel
over Model
, should we just dump Model
entirely and only work with FlatModel
? Maybe for this toy example that wouldn't be such a bad idea, but there's definitely downsides to doing this.
One such downside is that the FlatModel
type doesn't enforce each body is orbiting another body that exists. We can define Worf as { population = 1, orbiting = Just "Teapot" }
, without there being Teapot
in the solar system. Put another way, FlatBody
allows more 'impossible states' then Body
, and as a rule it's nice to avoid these when we can.
Another disadvantage is choosing a Model type to simplify our update logic can complicate our view logic. It's not inconceivable the tree structure of the original Model
will prove convenient for drawing: We will be able to first draw the sun, then the earth around it, then the moon around the earth, etc. The FlatModel
doesn't provide us the planetary bodies in a useful order. So changing the Model
type will simplify the update logic, but maybe at the expense of complicating the view
logic.
Ideally we'd like to use both types for what they're good at. We keep our Model
type, so we prevent a bunch of impossible states, but for updates we use our FlatModel
type which is easier to manipulate. We can do that, but we'll need to conversion functions between both types. For now I'll just give the types of these conversion functions:
toFlatModel : Model -> FlatModel
toFlatModel = Debug.todo "We'll need to implement this function."
fromFlatModel : FlatModel -> Maybe Model
fromFlatModel = Debug.todo "And this function as well."
You may notice that fromFlatModel
returns a Maybe
. This is because, as we saw before, FlatModel
allows certain impossible states. We can't convert such a state to a Model
which doesn't allow these states, so we'll return Nothing
in that case.
Once we implement these conversion functions, we can use them to implement our update
function.
update : Msg -> Model -> Model
update msg model =
case msg of
SetPopulation body newPopulation ->
toFlatModel model
|> setPopulationOf body newPopulation
|> fromFlatModel
|> Maybe.withDefault model
Move { body, destinationOrbit }
toFlatModel model
|> moveBodyTo body destinationOrbit
|> fromFlatModel
|> Maybe.withDefault model
And with that we're almost done. The one thing left to do is to implement those conversion functions. I'm not going to print those here because of their length, but you can check them out in the full code of this post.
Was it worth it?
We achieved what we set out to do: we have a simple update function without compromising the strength of our Model
type. For this we payed with the need to create an additional FlatModel
type and two conversion functions. Was that price worth the gain?
Maybe the use of two different types for the same concept sets of some warnings sounds. It's repetition of a sort, something we're often told to avoid in order to achieve maximum code re-use. You'll have to make your own decision, but I'll trade a reduction of code complexity for an additional type any day.
But did we reduce code complexity? We ended up writing two new conversion functions that aren't exactly simple themselves. Maybe it's more honest to say we moved complexity around the app, away from the update functions and into these conversion functions. I still call that a win, because I believe conversion functions are the best kind of complicated code you can have. To support that point I'd like to take the final part of this post to look at the benefit of conversion functions.
Pitching conversion functions
Let's take a look at some nice properties of conversion functions.
Conversion functions don't leak their problems. Conversion functions can be messy, but at worst they are an Omega mess, to use a term coined by Sandi Metz. No matter how messy the conversion function, it's always confined between these two types it converts between. There's no risk of the mess spilling over into other parts of the application, resulting in spaghetti code. I find that a comforting thought.
Conversion functions are easy to write. The Elm compiler is at its most helpful when you're writing conversion functions. It doesn't know anything about solar systems, or any other domain, so it can't help you much with that. If we by accident clone Worf instead of moving him, that's not the type of error the compiler picks up on. But conversion functions, that take one specific type and return another, are something the Elm compiler really gets. In our example application we had a lot of conversion code and only a small amount of domain-manipulation code, and so we've optimized our app to play to the compiler's strengths.
Conversion functions are easy to test. If you ever wondered what would be a good place to use the functions in elm-test
's Fuzz
module: this is it. Conversion functions change the way data is represented without changing the meaning of that data. For example: the conversion functions in the solar system example don't change which planets exist, or their populations. We can test that these 'rules' hold for any random solar system we generate.
The solar system example allows an even nicer test, because we have one conversion function each way: We can check that if we convert a random solar system one way and then back, we always end up with the same solar system we started with. A single such 'roundtrip test' will easily cover more failure scenario's then you would come up with yourself.
Writing conversion functions is a trainable skill. The conversion functions for the example in this post aren't simpler than the complicated update logic they would replace. But as we write more conversion functions, we'll start to recognize we're applying the same couple of tricks over and over again. Business logic will be different from one app to the next, but the writing of conversion function is a cross-domain skill that can be trained.
That's a wrap
We've seen an example of pushing code complexity into conversion functions, and I've tried to sell you on this strategy. Thank you for reading! I'd love to hear about your own experiences, good and bad, writing conversion functions.
-
Elm 0.19 introduced some nice support for interplanetary Elm programmers: it supports local elm package caching! ↩
Top comments (1)
Interesting read! I think I need to try using the
Dict
type more. I default to theList
and end up doing a lot of filtering when searching for stuff.