I have been writing Elm code for a recent project. It is great at the start, but then the model gets larger and complexity creeps in. Files grow over 100 lines in length. That's a sign of trouble.
The official Elm Guide offers a few words about reuse and it is worth giving it a read. However, it is anchored in reuse of view code and does not offer any information about decomposing the Update and Model monoliths.
How to best dissolve my ever expanding Update.elm
and Model.elm
?
tl;dr: In a monolithic structure,
Model.elm
is where you define the model of your data and the ways it can be changed.Update.elm
is where you define how the changes are implemented. Decomposing these files without losing expressive power is a feature of a maintainable code base.
The Model almost always becomes a set of key-value pairs.
type alias Model = -- Don't worry about the type alias bit
{ count: Int
, name: String
, age: Int
, height: Int
}
One of these keys is not like the others. name
, age
, and height
could all be attributes of a Person
where as count
seems unrelated. Grouping keys makes it easier to chunk the Model and the code required to update it. Consider the following redefinition of the Model.
type alias Person =
{ name: String
, age: Int
, height: Int
}
type alias Model =
{ count: Int
, person: Person
}
A Model is useless if it can't be changed. Events that change the model are specified in the Msg
type (short for Message). A Msg
is sent, along with the current Model
, to the update
function defined in Update.elm
upon certain events that you define such as button presses or a timeout. Let's define some changes to our Model
.
type Msg
= IncrementCount
| DecrementCount
| ChangePerson Person
These messages are fed into Update.elm
update: Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
IncrementCount -> ({ model | count = model.count + 1 }, Cmd.none)
DecrementCount -> ({ model | count = model.count - 1 }, Cmd.none)
ChangePerson person ->
({ model | person = person }, Cmd.none)
What if we wanted a finer-grained approach to updating our person
? We could redefine Msg
.
type Msg
= IncrementCount
| DecrementCount
| ChangeAge Int
| ChangeHeight Int
| ChangeName String
A downside to this implementation is that the last three Messages are not chunked at the type level. Consider this implementation that chunks these messages together.
type PersonMsg
= ChangeAge Int
| ChangeHeight Int
| ChangeName String
type Msg
= IncrementCount
| DecrementCount
| PersonMessage PersonMsg
Now we have a PersonMessage
that can wrap any of our PersonMsg
s. However, my personal opinion is that PersonMsg
is a clumsy name that does not scale well. Keeping track of an ever growing list of xxxMsg
type names sounds unpleasant. And this brings us to the crux of my problem. What is an effective way to decompose the Model
and Msg
?
I started with this blog post's ideas, but ended up with an update function that could not call itself recursively because the code was in different files. (demonstrated in this gist)
But then I came across this repository on GitHub. I had a fresh module to write and was willing to try something new.
The idea is to break out disjoint chunks of your Model
into different modules. I'll demonstrate using our previous example.
-- Starting from this:
-- elm-project
-- │ Model.elm
type alias Person =
{ name: String
, age: Int
, height: Int
}
type alias Model =
{ count: Int
, person: Person
}
type PersonMsg
= ChangeAge Int
| ChangeHeight Int
| ChangeName String
type Msg
= IncrementCount
| DecrementCount
| PersonMessage PersonMsg
-- Ending with this:
-- elm-project
-- │ Model.elm
-- | Person.elm
-- Person.elm
module Person exposing (Msg(..), Model)
type alias Model =
{ name: String
, age: Int
, height: Int
}
type Msg
= ChangeAge Int
| ChangeHeight Int
| ChangeName String
-- Model.elm
module Model exposing (Msg(..), Model)
import Person
type Msg
= IncrementCount
| DecrementCount
| PersonMessage Person.Msg
type alias Model =
{ count: Int
, person: Person.Model
}
Being able to reuse Model
and Msg
reduces the number of type names that the developer needs to remember. This is especially useful when the types serve the same function.
How does this translate to our top-level update function?
-- Update.elm
import Model exposing (Model, Msg)
import Person
update: Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
IncrementCount -> ({ model | count = model.count + 1 }, Cmd.none)
DecrementCount -> ({ model | count = model.count - 1 }, Cmd.none)
PersonMessage msg ->
let
(person, personCmd) = Person.update msg model.person
in
({ model | person = person }, Cmd.map PersonMessage personCmd) -- woah
We're able to delegate Person.Msg
s to the Person module, but something peculiar happens when we try to return from the update function. We have to map Cmd Person.Msg
to Cmd Msg
. This represents a decision that this decomposition strategy makes. Once you create a module, it can never impact any other part of your application. Any commands that arise from updates in the Person module will be passed right back into it. This makes some intuitive sense. A module is a self-contained piece of functionality, but is there really no way to expose an interface to other parts of the application?
Yes really. Modules should not know anything about their caller as they could be brought into any program. But sometimes it feels as though it really should be able to. And I'm going to posit that this is a symptom that it is time for a re-examination of the level of abstraction of the top-level Elm program. What was once the top is now a module that needs a new meta-layer to contain it.
In the repo, an example is shown of how to break out a "SimpleModule" and a "ComplexModule". The SimpleModule is defined in 1 file: Model, Update, View, and Subscriptions. (Might be a good idea to provide a succinct explanation or diagram of the Elm architecture) The ComplexModule is defined across multiple files in a folder called "ComplexModule".
init : (Model, Cmd Msg)
init =
let
(simpleModule, simpleCmd) = SimpleModule.init -- The interfaces are different
(complexModule, complexCmd) = ComplexModule.State.init -- The interfaces are different
model =
{ simpleModuleModel = simpleModule
, complexModuleModel = complexModule
}
in
( model
, Cmd.batch
[ Cmd.map SimpleMsg simpleCmd
, Cmd.map ComplexMsg complexCmd
]
)
The SimpleModule and the ComplexModule are both modules, but one has a leakier abstraction. SimpleModule.ini
versus ComplexModule.State.init
. The ComplexModule exposes the fact that it has an internal module called State
.
The difference comes from the file structure of the codebase. Everything inside the ComplexModule folder can be accessed only be ComplexModule.<thing>
. Any "simple" module that becomes "complex" (defined across multiple files) will need to be refactored from SimpleModule.init
to SimpleModule.State.init
. Systematic refactoring to a leaky abstraction is not a desirable feature of a codebase. (Opinion: People should not be punished for modularization).
elm-project
│ App.elm
│ State.elm
│ Types.elm
| View.elm
| SimpleModule.elm
| index.html
| manifest.json
└───ComplexModule
│ │ State.elm
│ │ Types.elm
│ │ View.elm
A solution is to add one more file:
elm-project
│ App.elm
│ State.elm
│ Types.elm
| View.elm
| SimpleModule.elm
| index.html
| manifest.json
| ComplexModule.elm -- This one!
└───ComplexModule
│ │ State.elm
│ │ Types.elm
│ │ View.elm
ComplexModule.elm
looks like this:
module ComplexModule exposing (Model, Msg, init, update, view)
import ComplexModule.Types as Types
import ComplexModule.State as State
import ComplexModule.View as View
type alias Model = Types.Model
type alias Msg = Types.Msg
init : (Model, Cmd Msg)
init = State.init
update = State.update
view = View.view
The interfaces are now the same:
-- New
(simpleModule, simpleCmd) = SimpleModule.init
(complexModule, complexCmd) = ComplexModule.init
-- Original
(simpleModule, simpleCmd) = SimpleModule.init
(complexModule, complexCmd) = ComplexModule.State.init
ComplexModule.elm
is 100% boilerplate. I don't mind typing the files out by hand or automating the creation of these wrapper files. What I wonder is that if this is the way to structure Elm apps, then that would imply that this sort of code generation should in theory become part of the Elm compiler/module system. What might that entail?
You can do a trick with higher kinded types to derive the commands for you given a model. Consider parameterizing the model :
Now given
Person Identity
you get the model originally written. ButPerson Maybe
gives you replacement semantics. And giventype Update a = a -> a
,Person Update
gives you update semantics.All you need to do is write a function
(forall a. a -> f a -> a) -> Person Identity -> Person f -> Person Identity