Skip to content

Instantly share code, notes, and snippets.

@eh-dub
Last active August 6, 2017 21:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save eh-dub/f4fd8fdbe340ab03dcef15a0bf7e6138 to your computer and use it in GitHub Desktop.
Save eh-dub/f4fd8fdbe340ab03dcef15a0bf7e6138 to your computer and use it in GitHub Desktop.
Managing Complexity in Elm

Managing Complexity in Elm

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?

Update and Model

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 PersonMsgs. 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?

A Strategy for Composition

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.Msgs 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.

The Interfaces are Different

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

The Interfaces are the Same

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?

@tngoon
Copy link

tngoon commented Aug 1, 2017

  • What is Update.elm and Model.elm?
  • What is leakier abstraction?
  • What does it mean to refactor?

Overall, I understood the aspect of simple vs. complex and how this becomes a problem as the codebase gets larger and more complex

@isovector
Copy link

You can do a trick with higher kinded types to derive the commands for you given a model. Consider parameterizing the model :

type alias Person f =
	{ name: f String
    , age: f Int
    , height: f Int
	}

Now given

Person Identity you get the model originally written. But Person Maybe gives you replacement semantics. And given type 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

@HazardousPeach
Copy link

Hmm, I don't know if I really see ComplexModule.elm as pure boilerplate. It's really defining an interface for the ComplexModule structure, which in different contexts you'll want to restrict in different ways. For instance, you might want to use it to encapsulate some data, preventing code outside of the ComplexModule folder from manipulating certain state. You also might need to initialize some internal state outside of the ComplexModule.State module, so your ComplexModule.init function could call multiple init functions internally.

Also, you're missing a "t" in the first paragraph after the first code example in the "The Interfaces Are Different", SimpleModule.ini -> SimpleModule.init

Finally, future readers might appreciate some sort of outline paragraph at the top to organize the post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment