Previously I've written about immutability bugs which are bugs that are more likely in an immutable language than a mutable one. I think these are relatively rare, but they do exist. A good example has come up on the Elm discourse.
The person asking the question wants to create new unique identifiers for items in their model. To do this you can simply keep a count of the number of identifiers you have thus far created. So you can do something like the following:
1type alias Id =
2 String
3type alias Model =
4 { ....
5 , idsSoFar : Int
6 }
7
8createNewId : Model -> (Id, Model)
9createNewId model =
10 ( String.fromInt model.idsSoFar
11 |> String.append "id-number-"
12 , { model | idsSoFar = model.idsSoFar + 1 }
13 )
All good, however, the possibility for a bug is relatively high here. In your update function, if you use the createNewId
you must make sure that you remember to store the new model. Here's a potential bug:
1update : Msg -> Model -> (Model, Cmd Msg)
2update message model =
3 case message of
4 ...
5 NewThing ->
6 let
7 (newId, newModel) =
8 createNewId model
9 newThing =
10 Thing.empty newId
11 in
12 ( { model | things = newThing :: model.things }
13 , Cmd.none
14 )
You see the bug, I've accidentally updated the original model rather than newModel
. This is one reason why using static analysis tools such as elm-review
is important. Such tools will warn you about the defined-but-unused name newModel
and hopefully you can correct the error.
Could we find a way to make sure this bug doesn't happen? Yes we could, but it's not pretty. One way to do this is to define your Model
type as an opaque type (this just means making it a custom tagged union type but not exporting the constructors). So, you can do the following in Model.elm
:
1module Module exposing (State, Id, Model, update, updateWithNewId)
2
3type alias Id =
4 String
5
6type alias State a =
7 { things : List Thing
8 , ...
9 }
10type Model =
11 Model (State { idsSoFar : Int} )
12
13update : (State a -> State a) -> Model -> Model
14update updateState model =
15 case model of
16 Model state ->
17 Model (updateState state)
18
19updateWithNewId : (Id -> State a -> State a) -> Model -> Model
20updateWithNewId updateState model =
21 case model of
22 Model state ->
23 let
24 newId =
25 String.fromInt state.idsSoFar
26 |> String.append "id-number-"
27 newState =
28 { state | idsSoFar = state.idsSoFar + 1 }
29 in
30 Model (updateState newId newState)
31
You could also make the Id
type opaque so that it is impossible to create one without using this module.
I think this basically solves the issue, but it's pretty far from pretty. Your update function looks like this:
1update message model =
2 case message of
3 Tick now ->
4 ( Model.update (\s -> { s | now = now }) model
5 , Cmd.none
6 )
7 ....
8 NewThing ->
9 let
10 updateFun newId state =
11 { state | things = Thing.empty newId :: model.things }
12 in
13 ( Model.updateWithNewId updateFun model
14 , Cmd.none
15 )
16 ...
You could probably make this a little more palatable by separating out your messages into those that require a new Id and those that do not and then just matching within those.