Lessons learned from my first 10k LOC in Elm
I have been working on a personal project of mine for the last couple of months that has the frontend written in Elm. So far everything is going great and the project is around 10k lines of code.
I have noticed a few reoccurring patterns of mine that I have learned so far and want to share, so here are 5 things I have learned:
1. Decoding Empty Lists & Strings⌗
When I first started the project, I had a lot of types with fields declared like this:
type alias Something =
{ name : Maybe String
, stuff : Maybe (List String)
}
and the way I was decoding these values from my API was like this:
import Json.Decode as D
import Json.Decode.Pipeline exposing (optional)
D.succeed Something
|> optional "name" (D.nullable D.string) Nothing
|> optional "stuff" (D.nullable (D.list D.string)) Nothing
This was fine at first, but I noticed that after awhile I was repeating logic inside my update functions to check if the name
field was an empty string, or if the stuff
field was an empty list, and if it was, change the value to Nothing
instead of Just ""
or Just []
.
So after awhile, I thought it would be more efficient to check for empty strings and empty lists during the decoding phase, so I came up with two decoder helper functions, decodeNonEmptyString
and decodeNonEmptyList
:
decodeNonEmptyString : D.Decoder (Maybe String)
decodeNonEmptyString =
D.andThen (notEmptyString >> D.succeed) D.string
decodeNonEmptyList : D.Decoder a -> D.Decoder (Maybe (List a))
decodeNonEmptyList l =
D.andThen (notEmptyList >> D.succeed) (D.maybe (D.list l))
notEmptyList : Maybe (List a) -> Maybe (List a)
notEmptyList =
Maybe.andThen (\l ->
if List.isEmpty l then
Nothing
else
Just l
)
notEmptyString : String -> Maybe String
notEmptyString s =
if String.isEmpty s then
Nothing
else
Just a
So now my original decoders use these new utilities like this:
D.succeed Something
|> optional "name" decodeNonEmptyString Nothing
|> optional "stuff" (decodeNonEmptyList D.string) Nothing
And I don’t need to check for emptiness anymore in my update functions 🎉
2. Flatter pattern matching⌗
This one might seem really obvious, but took me awhile to realize that doing this results in flatter pattern matching functions.
If I have a type like RD.WebData (Maybe (List Person))
, the way I original matched on it was like this:
case response of
RD.NotAsked -> div [] [text "Not Asked"]
RD.Loading -> div [] [text "Loading"]
RD.Failure e -> div [] [text "Error"]
RD.Success maybeData ->
case maybeData of
Nothing -> div [] [text "No data"]
Just data -> div [] [text "Data!"]
But I realized that I could flatten this into one case statement instead of 2 separate ones like this:
case response of
RD.NotAsked -> div [] [text "Not Asked"]
RD.Loading -> div [] [text "Loading"]
RD.Failure e -> div [] [text "Error"]
RD.Success Nothing -> div [] [text "No data"]
RD.Success (Just data) -> div [] [text "Data!"]
See? Much nicer!
3. Select Wrapper⌗
This is one of the first abstractions I made when I started because I wanted an easy reusable way to use select [] []
and found that the native one in Elm is not as nice as I hoped.
So I wrote a small wrapper called viewSelect
:
viewSelect : Bool -> (String -> msg) -> List Option -> Html msg
viewSelect disabled changeMsg options =
select
[ onChange changeMsg
, Html.Styled.Attributes.disabled disabled
]
(List.map
(\opt ->
option
[ value opt.value
, Html.Styled.Attributes.selected opt.selected
, Html.Styled.Attributes.disabled opt.disabled
]
[ text opt.text ]
)
options
)
type alias Option =
{ text : String
, value : String
, selected : Bool
, disabled : Bool
}
onChange : (String -> msg) -> Html.Styled.Attribute msg
onChange tagger =
on "change" (D.map tagger Html.Styled.Events.targetValue)
Now I use viewSelect
instead of the native select
and I just need to pass in a list of Option
instead. Every time a user changes the select box, the changeMsg fires with the new value.
4. ChangeField Pattern⌗
In my app, I have a lot of forms, and every form needs to modify some field inside my model. The way I originally started doing this was:
type Msg
= ChangeName String
| ChangeAge String
| ChangeHeight String
| ChangeWeight String
update msg model =
case msg of
ChangeName name -> ({ model | name = name }, Cmd.none)
ChangeAge age -> ({ model | age = age }, Cmd.none)
ChangeHeight height -> ({ model | height = height }, Cmd.none)
ChangeWeight weight -> ({ model | weight = weight }, Cmd.none)
This was also fine at first, but my app has A LOT of forms and this was not scaling well. So instead of creating a new Msg type for every field, I created one Msg type for updating any field. And it works like this:
type Msg = ChangeField (String -> Person -> Person) String
update msg model =
case msg of
ChangeField setter content -> (setter content model, Cmd.none)
Now every time a field needs to change, instead of calling ChangeName "Jason"
, I can now call ChangeField setName "Jason"
where setName equals:
setName : String -> Person -> Person
setName content person =
{ person | name = content }
This allows me to create individual setter functions for each field and do any data manipulations from String into any type I need.
It simplifies my update function by reducing the amount of Msg types it can match on, and it nicely separates out my data manipulation logic for each field.
5. Posix Wrappers⌗
This one is pretty simple really. I use Elm’s Time.Posix
type a lot around my app, so I needed functions to format, manipulate and display Posix in my UI. I came up with a few helpers for this and call it my HumanTime module:
humanDateTime : Zone -> Posix -> String
humanDateTime z p =
humanDate z p ++ " " ++ humanTime z p
humanTime : Zone -> Posix -> String
humanTime z p =
humanHour z p ++ ":" ++ humanMinute z p
humanDate : Zone -> Posix -> String
humanDate z p =
humanMonth z p ++ " " ++ humanDay z p ++ ", " ++ humanYear z p
humanYear : Zone -> Posix -> String
humanYear z =
toYear z >> String.fromInt
humanDay : Zone -> Posix -> String
humanDay z =
toDay z >> String.fromInt
humanHour : Zone -> Posix -> String
humanHour z =
toHour z >> String.fromInt
humanMinute : Zone -> Posix -> String
humanMinute z =
toMinute z
>> (\m ->
if m > 9 then
String.fromInt m
else
"0" ++ String.fromInt m
)
humanMonth : Zone -> Posix -> String
humanMonth z p =
case toMonth z p of
Jan -> "Jan"
Feb -> "Feb"
Mar -> "Mar"
Apr -> "Apr"
May -> "May"
Jun -> "Jun"
Jul -> "Jul"
Aug -> "Aug"
Sep -> "Sep"
Oct -> "Oct"
Nov -> "Nov"
Dec -> "Dec"
And that is it so far! I am sure I will learn even more in the next 10k LOC that I write and might even figure out better ways of doing these things that I just mentioned.
Elm is an amazing tool for creating frontends and I can’t ever see myself going back to React or any other non-functional frameworks for personal projects.