loading...

Conversion functions, five stars

jwoudenberg profile image Jasper Woudenberg Updated on ・7 min read

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 colonize 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 = "Elon's rocketship"
                        , 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 Elon to Mars:

  1. First we need to find Elon somewhere in the solar system and store him in a variable.
  2. Then we need to find the body Elon is orbiting, and remove Elon as a moon of that body.
  3. And last we need to find the body Elon is moving to, and insert the copy of Elon 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 Elon 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 Elon 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 Elon 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 Elon 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.


  1. Elm 0.19 introduced some nice support for interplanetary Elm programmers: it supports local elm package caching! 

Posted on by:

jwoudenberg profile

Jasper Woudenberg

@jwoudenberg

Functional programming enthousiast @NoRedInk.

Discussion

markdown guide
 

Interesting read! I think I need to try using the Dict type more. I default to the List and end up doing a lot of filtering when searching for stuff.