TL;TR: using type parameters we can simplify the main update function, moving the logic to the submodule.
The code for this post is available on a github repository.
Achieving modularity in Elm is at the same time easy and hard.
It is easy because it’s almost impossible to break things even with big refactorings but on the other hand the constraints of pure functional programming can be challenging when deciding how to split the application into submodules.
The model
Imagine you have a Model describing a basic shopping cart:
1 2 3 4 5 |
type alias Model = { name : String , details : Details.Model , invoice : Invoice.Model } |
Where the models in modules Details and Invoice are defined as:
1 2 3 4 5 6 7 8 9 10 |
-- Details.elm type alias Model = { quantity : Int } -- Invoice.elm type alias Model = { price : Float } |
This is an oversimplified example, just to show a basic modules subdivision, where we have only two messages: Increment
and Decrement
.
The isolated-model approach
The update functions
In the Details module, the update
function will take care of updating the quantity
field summing the input number, just preventing it from being assigned a negative amount:
1 2 3 4 5 6 |
update : Int -> Model -> Model update number model = if model.quantity == 0 && number < 0 then model else { model | quantity = model.quantity + number } |
In the Invoice module, the update
function will take care of updating the total price based on the quantity value passed as an input:
1 2 3 |
update : Int -> Model -> Model update quantity model = { model | price = (toFloat quantity) * 1.5 } |
Finally, the main update
function is in charge of running the submodules’ functions and put together the data for the new model. This is the code handling the Increment
message:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
update msg model = case msg of Increment -> let newDetails = Details.update 1 model.details newInvoice = Details.mapQuantity Invoice.update model.invoice newDetails in { model | details = newDetails , invoice = newInvoice } Decrement -> -- Same with -1 |
Let’s break it down: first, I calculate the new model for Details since its result will be used to calculate the price and I store it in newDetails
.
Subsequently, I calculate the new model for Invoice using the helper function Details.mapQuantity
since I don’t want the inner structure of Details model to be exposed. The helper function itself is simply:
1 2 3 4 5 |
-- Details.elm mapQuantity : (Int -> a -> b) -> a -> Model -> b mapQuantity f input model = f model.quantity input |
Let alone the fact that the code can be written in a more concise (and somehow difficult to read) way, this pattern (which I called the Isolated Model) makes the main update
function verbose and prone to errors, given that a simple application could be composed of tens of modules.
The most common mistake that I do is to assign the wrong model to the final model output:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let newDetails = Details.update 1 model.details newInvoice = Details.mapQuantity Invoice.update model.invoice newDetails -- ...more logic here... newOtherModule = -- ERROR: the model I'm passing here is the one updated -- by Details. The compiler won't complain, but the result will be -- wrong. OtherModule.doSomethingBasedOnInvoice newDetails in -- ... |
The SuperModel approach
In Elm, I can tell the compiler that I want a record with some specific properties defined regardless of what else it contains:
1 2 3 4 5 6 |
type alias Model = { price : Float } type alias SuperModel a = { a | invoice : Model } |
It means that SuperModel is a union of a record of type a and a record with a field invoice
of type Model.
We can now define the updates functions in terms of SuperModel a:
1 2 3 4 5 6 7 8 9 10 11 |
-- Invoice.elm update : Int -> SuperModel a -> SuperModel a update quantity superModel = let model = superModel.invoice newModel = { model | price = (toFloat quantity) * 1.5 } in { superModel | invoice = newModel } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
-- Details.elm update : Int -> SuperModel a -> SuperModel a update number superModel = let model = superModel.details in if model.quantity == 0 && number < 0 then superModel else let newModel = { model | quantity = model.quantity + number } in { superModel | details = newModel } |
As you can see, the functions are a bit more verbose than before: we just moved the complexity into the single modules, thus allowing the Main update
function to become simpler:
1 2 3 4 5 6 7 8 9 10 |
update : Msg -> Model -> Model update msg model = case msg of Increment -> Details.update 1 model |> Details.mapQuantity Invoice.update Decrement -> Details.update -1 model |> Details.mapQuantity Invoice.update |
The main advantage in this scenario is that we can pipe the update functions (and even the map one) since they all accept the same input.
I’ve found this pattern pretty clean and usable because now my Main is easier to read and all the functions are guaranteed to receive as input the latest updated Model.
There is still room for logical errors though: for instance, I could have two interdependent updates be run in the wrong order. But with the piping syntax, I find them easier to spot and fix.
1 Comment
Join the discussion and tell us your opinion.
You can (potentially) clean this up further using pattern-matching: https://ellie-app.com/3Mx5qb9tzx7a1/0