Writing better Elm code with simple List transformations
A practical walkthrough of using List.map in Elm
An Elm application may have view functions that need to traverse nested records.
For instance (in Listing 1), we see a Team
record that consists of a list of Board
records, consisting of a list of Stage
records, that also consists of a list of Card
records.
-- Listing 1
type alias Team =
{ id : String
, name : String
, boards : List Board
}
type alias Board =
{ id : String
, name : String
, stages : List Stage
}
type alias Stage =
{ id : String
, boardID : String
, name : String
, cards : List Card
}
type alias Card =
{ id : String
, teamID : String
, boardID : String
, stageID : String
, desc : String
}
View on github (Ex1.elm L10 - L38)
Our intent is to use these records to generate a view similar to the figure 1, below.
The trivial approach is to simply iterate through each nested record (see Listing 2).
-- List 2
-- ... style omitted for brevity
view : Model -> Html Msg
view (Model team) =
div
[...]
[ h3
[...]
[ text ("Team Name: " ++ team.name) ]
, viewBoardAt 1 team
]
viewBoardAt : Int -> Team -> Html Msg
viewBoardAt boardIndex team =
div
[...]
(team.boards
|> List.indexedMap
(\index board ->
if index == boardIndex then
div
[]
[ h4 [] [ text <| board.name ++ " Board" ]
, div
[...]
(board.stages
|> List.map
(\stage ->
div
[...]
[ div
[...]
[ text stage.name ]
, div
[ onClick <| NewCard stage
, ...]
[ text "Add Card" ]
, div
[...]
(List.map
(\card ->
div
[...]
[ span
[...]
[ text <| card.id ]
, span [] [ text card.desc ]
]
)
stage.cards
)
]
)
)
]
else
text ""
)
)
View the full code list on github (Ex1.elm).
While the approach works, it results in code that is difficult to easily understand.
This makes it harder to debug and maintain the function as the records become more complex. Additionally, testing is also difficult with this large block of code due to the hidden dependencies.
Make it obvious
The first step to creating more readable and more maintainable code is to make the intent obvious.
For instance we could break the viewBoardAt
function into smaller separate functions (see Listing 3).
-- Listing 3
viewBoardAt : Int -> Team -> Html Msg
viewBoardAt boardIndex team =
div
[...]
(updateIfIndexWithDefault
(\index -> index == boardIndex)
(\board ->
viewBoard board
)
(\_ -> text "")
team.boards
)
viewBoard : Board -> Html Msg
viewBoard board =
div
[]
[ h4 [] [ text <| board.name ++ " Board" ]
, div
[...]
-- hardcoded dependency between viewBoard & viewStage
(List.map (\s -> viewStage s) board.stages)
]
viewStage : Stage -> Html Msg
viewStage stage =
div
[...]
[ div
[ style "font-weight" "bold"
]
[ text stage.name ]
, div
[ onClick <| NewCard stage
, ...]
[ text "Add Card" ]
, div
[...]
-- hardcoded dependency between viewStage & viewCard
(List.map (\card -> viewCard card) stage.cards)
]
viewCard : Card -> Html Msg
viewCard card =
div
[...]
[ span
[...]
[ text <| card.id ]
, span [] [ text card.desc ]
]
View the full code list on github (Ex2.elm).
We can now see that we have 4 logical viewing operations:
viewBoardAt
- Get a specific board for viewingviewBoard
- Generate the view for a board and its stagesviewStage
- Generate the view for a Stage in the board and its cardsviewCard
- Generate html for Card only.
This is definitely an improvement. However, we maintained a hard dependency by using the List.maps
in viewBoard
and viewStage
.
Ideally, what I feel is better is if the viewBoard
and viewStage
accept html for their nested children and then they just render that Html.
This results in code that is easier to test and read.
Make it easy to test
In listing 4, the code has been modified to code to accept a list html for the children nodes. This breaks the dependencies since the parent functions (viewBoard
and viewStage
) don’t need to know how their child Html elements (stagesHtml
and cardsHtml
) are generated.
-- Listing 4
viewBoard : Board -> List (Html Msg) -> Html Msg
viewBoard board stagesHml =
div
[]
[ h4 [] [ text <| board.name ++ " Board" ]
, div
[...]
stagesHml
]
viewStage : Stage -> List (Html Msg) -> Html Msg
viewStage stage cardsHtml =
div
[...]
[ div
[...]
[ text stage.name ]
, div
[ onClick <| NewCard stage
, ...]
[ text "Add Card" ]
, div
[...]
cardsHtml
]
View the full code list on github (Ex3.elm).
This would be a good place to stop… but, could we make it more expressive?
Make it even more expressive
Note : The code in Listing 4 (Ex3.elm above) is Good Enough! Everything below is just for the curious.
The viewBoardAt
function could do with a sprinkling of some love.
The List.maps
are not expressive enough and we can easily fix this, by trading brevity for succinctness.
-- Listing 5
-- From (Ex3.elm)
viewBoardAt : Int -> Team -> List (Html Msg)
viewBoardAt boardIndex team =
updateIfIndexWithDefault
(\index -> index == boardIndex)
(\board ->
viewBoard board <|
List.map
(\s ->
viewStage s <|
List.map viewCard s.cards
)
board.stages
)
(\_ -> text "")
team.boards
-- To (Ex4.elm)
viewBoardAt : Int -> Team -> Html Msg
viewBoardAt boardIndex team =
div
[...]
(updateIfIndexWithDefault
(\index -> index == boardIndex)
(\board ->
viewBoard board
(composeHtml board.stages
(\s ->
viewStage s (composeHtml s.cards viewCard)
)
)
)
(\_ -> text "")
team.boards
)
composeHtml : List a -> (a -> Html Msg) -> List (Html Msg)
composeHtml list fnc =
List.map fnc list
View the full code list on github (Ex4.elm).
The addition of composeHtml
makes it clearer what we are trying to do in the viewBoardAt
function.
Too much, maybe?
Going further, we could include a full set of smaller helper functions (see Listing 6).
-- Listing 6
viewBoardAt : Int -> Team -> Html Msg
viewBoardAt boardIndex team =
div
[ style "padding" "20px"
]
(team.boards
|> takeOnlyIndex boardIndex
|> bHtml
)
cInnerHtml : Card -> Html Msg
cInnerHtml item =
viewCard item
sInnerHtml : Stage -> Html Msg
sInnerHtml item =
viewStage item (cHtml item.cards)
bInnerHtml : Board -> Html Msg
bInnerHtml item =
viewBoard item (sHtml item.stages)
cHtml : List Card -> List (Html Msg)
cHtml cards =
List.map viewCard cards
sHtml : List Stage -> List (Html Msg)
sHtml stages =
List.map sInnerHtml stages
bHtml : List Board -> List (Html Msg)
bHtml boards =
List.map bInnerHtml boards
takeOnlyIndex boardIndex list =
list
|> List.indexedMap (\index item -> ( index, item ))
|> List.filter (\( index, _ ) -> index == boardIndex)
|> List.map (\( _, item ) -> item)
View the full code list on github (Ex5.elm).
Now the code is easier to read and reason about. However, we have unfortunately have introduced dependencies in the helper functions that defeat our earlier gains. At this point, it’s just a perspective on taste.
Whatever your preference, writing good code requires deliberate thought.