Exploring Elm: AoC Day 17 + Declarative Web UI in a Functional Language
#post #pl #web #aoc #Blog
I have been using this year’s Advent of Code (AoC) as an opportunity to try solving problems in different languages. So far, I did days 1 to 16 in 9 different languages that I at least had some familiarity with, but it seems like I’m running out of languages for the remaining days.
I came across Elm, a nice functional language for building web UI—something I once only associated with JavaScript. JavaScript sucks type… and arguably not even TypeScript can save it, with the lack of algebraic data type making TypeScript a dealbreaker for me.
Looking just at part 11, day 17 seemed like a pretty good excuse to learn Elm.
H2 The architecture
Elm sets things up in a way that a program or web page is simply a main : Program () Model Msg
. You get to define the type Model
and Msg
. Model
can be thought of the program’s state, and Msg
can be thought of events that cause a state transition.
Initialising a simple web app involves specifying three things:
main =
Browser.sandbox {
init = init,
view = view,
update = update
}
init
is a value of type Model
that acts as the initial state. view
is just a function Model -> Html Msg
that takes the program state and renders HTML. Then, there’s the update
function, which is just Msg -> Model -> Model
.
It used to be that, in JavaScript land, Msg
could just be string identifiers… which causes a horrible mess. With Elm, messages are values of a sum type of your choosing:
type Msg = SolvePart1 | SolvePart2 | Input String
With that, you get to pattern match—awesome!
case msg of
SolvePart1 -> ...
SolvePart2 -> ...
Input s -> ...
H2 Declarative UI
HTML elements can be created functionally. The button
function, for example, is a List (Attribute msg) -> List (Html msg) -> Html msg
. Attribute msg
s can be created using functions from Html.Attributes
. Here’s an example:
button [ onClick SolvePart1 ] [ text "compute part 1" ]
That’s it. Pretty convenient huh. Here, onClick
is a msg -> Attribute msg
, and the HTML attribute it creates says that, upon button click, call the update
function with message SolvePart1
. text
is a String -> Html msg
that puts some text in the HTML, nested inside the <button>
tag.
I imagine functions can be composed, partially applied, chained, etc. to create more complex components, all without messy HTML interleaved inside JavaScript as in how React handles JSX. There’s also no one-file-every-component situation as in Svelte that makes file management messy.
H2 Rendering execution trace
As a more complicated experiment, making an HTML table to trace the interpreter’s execution was rather fun. The result looks like this2:
The table’s code was quite elegant, at least I thought.
renderTrace : List ProgramState -> Html msg
renderTrace states =
let
renderRow state = tr [] [
td [] [ text (String.fromInt state.a) ],
td [] [ text (String.fromInt state.b) ],
td [] [ text (String.fromInt state.c) ],
td [] (
List.indexedMap (\i -> \x ->
let
opcode = x |> literalOperand
inner = span [] [ text (String.fromInt opcode ++ " ") ]
content =
if i == state.counter
then strong [ style "color" "red" ] [ inner ]
else inner
in content
) (Array.toList state.instrs)
)
]
rows = states |> List.map renderRow
tableHead = tr [] [
th [] [ text "Register A" ],
th [] [ text "Register B" ],
th [] [ text "Register C" ],
th [] [ text "Program" ]
]
in
tableHead :: rows |> table []
And to put things together:
view : Model -> Html Msg
view model =
div []
[
div [] [
textarea [ onInput Input, value model.input, cols 40, rows 6, placeholder "paste input here" ] [ ]
],
button [ onClick SolvePart1 ] [ text "compute part 1" ],
button [ onClick SolvePart2 ] [ text "compute part 2" ],
br [] [],
div [ style "margin-top" "10px" ] [
text "part 1 output:",
br [] [],
textarea [ disabled True, value model.output1, cols 40, rows 3, placeholder "nothing computed yet" ] []
],
div [ style "margin-top" "10px" ] [
text "part 2 output:",
br [] [],
textarea [ disabled True, value model.output2, cols 40, rows 3, placeholder "nothing computed yet" ] []
],
div [ style "margin-top" "10px" ] [
renderTrace model.trace
]
]
H2 Stuff I liked
- No more dealing with JavaScript’s
undefined
(which was probably the culprit behind most JavaScript debugging I’ve done) - No more horrible JavaScript type coercion
- No runtime error by default—I don’t think I can possibly write something that crashes unless I do something really weird
- Debug logging works for type
'a
, so no need to manually implement toString for everything (likely thanks to them being compiled to JavaScript objects) - Friendly error messages by a compiler called “I”
What a missing case pattern error message looks like:
-- MISSING PATTERNS /.../advent-of-code/2024/day17/src/Main.elm
This `case` does not have branches for all possibilities:
98|> case instr of
...
105|> Out -> Just { state | console = (modBy 8 (comboOperand operand state))::state.console}
106|> Bdv -> Just { state | b = (state.a // (2 ^ comboOperand operand state))}
Missing possibilities include:
Cdv
I would have to crash if I saw one of those. Add branches for them!
Hint: If you want to write the code for each branch later, use `Debug.todo` as a
placeholder. Read <https://elm-lang.org/0.19.1/missing-patterns> for more
guidance on this workflow.
H2 Stuff I wasn’t sure about
- The standard formatting for lists is to have commas at the start of the next line… WHAT?
- Is there no auto-refresh by default? I have been refreshing manually after every code change
- The lack of exception or convenient error propagation mechanism (see below)
The densely failable parsing code…
It’s nice that Elm comes with built-in Result
and Maybe
types. Usually, this helps with building robust programs, but this time it got in the way when I was to parse
AoC’s input string. Annoyingly, parsing could easily take multiple steps and each of those steps could fail.
My initial attempt was quite bad… and I ended up importing Unwrap
which defies the whole point of those monadic types.
type Instr = Adv | Bxl | Bst | Jnz | Bxc | Out | Bdv | Cdv | Noop
type alias ProgramState = {
a: Int,
b: Int,
c: Int,
instrs: Array.Array Instr,
counter: Int,
console: List Int
}
parseInput : String -> Result String ProgramState
parseInput input_str =
case String.split "\n" input_str of
lineA :: lineB :: lineC :: _ :: lineProg :: _ ->
let
getInit = String.split ":" >> Array.fromList >> Array.get 1 >> Unwrap.maybe >> String.trim >> String.toInt >> Unwrap.maybe
in
Ok {
a = getInit lineA,
b = getInit lineB,
c = getInit lineC,
instrs = String.split ":" lineProg |> Array.fromList |> Array.get 1 |> Unwrap.maybe |> String.trim |> String.split "," |> List.map (String.toInt >> Unwrap.maybe) |> List.map (\opCode -> case opCode of
0 -> Adv
1 -> Bxl
2 -> Bst
3 -> Jnz
4 -> Bxc
5 -> Out
6 -> Bdv
7 -> Cdv
_ -> Noop -- unreachable
) |> Array.fromList,
counter = 0,
console = []
}
_ -> Err "cannot parse"
It wasn’t obvious to me how to do this properly without making ugly nested casings… given there’s no way to throw and handle exceptions in Elm. After some playing around, the solution ended up using lots of Maybe.map : (a -> b) -> Maybe a -> Maybe b
, Maybe.andThen : (a -> Maybe b) -> Maybe a -> Maybe b
(reminiscent of Haskell’s >>=
), and some custom HOFs to deal with mapping a failable function on a list. It turns out that alias types can also be treated like a super curried function to construct something of that type. In my case, ProgramState : Int -> Int -> Int -> Array.Array Instr -> Int -> List Int -> ProgramState
. This made implementing parseInput
a bit easier.
type Instr = Adv | Bxl | Bst | Jnz | Bxc | Out | Bdv | Cdv
type alias ProgramState = {
a: Int,
b: Int,
c: Int,
instrs: Array.Array Instr,
counter: Int,
console: List Int
}
maybeListMap : (a -> Maybe b) -> List a -> Maybe (List b)
maybeListMap f l =
List.foldr (\x acc -> case (f x, acc) of
(Just fx, Just fxs) -> Just (fx :: fxs)
(Nothing, _) -> Nothing
(_, Nothing) -> Nothing
) (Just []) l
unnestMaybe : Maybe (Maybe a) -> Maybe a
unnestMaybe mma =
case mma of
Just ma -> ma
Nothing -> Nothing
parseInitValue : String -> Maybe Int
parseInitValue line =
String.split ":" line
|> Array.fromList
|> Array.get 1
|> Maybe.map String.trim
|> Maybe.andThen String.toInt
opcodeToInstr : Int -> Maybe Instr
opcodeToInstr opCode =
case opCode of
0 -> Just Adv
1 -> Just Bxl
2 -> Just Bst
3 -> Just Jnz
4 -> Just Bxc
5 -> Just Out
6 -> Just Bdv
7 -> Just Cdv
_ -> Nothing
parseInstructions : String -> Maybe (Array.Array Instr)
parseInstructions lineProg =
String.split ":" lineProg
|> Array.fromList
|> Array.get 1
|> Maybe.map String.trim
|> Maybe.map (String.split ",")
|> Maybe.map (maybeListMap String.toInt)
|> unnestMaybe
|> Maybe.map (maybeListMap opcodeToInstr)
|> unnestMaybe
|> Maybe.map Array.fromList
parseInput : String -> Result String ProgramState
parseInput inputStr =
case String.split "\n" inputStr of
lineA :: lineB :: lineC :: _ :: lineProg :: _ ->
Maybe.map4 ProgramState
(parseInitValue lineA)
(parseInitValue lineB)
(parseInitValue lineC)
(parseInstructions lineProg)
|> Maybe.andThen (\x -> Just (x 0))
|> Maybe.andThen (\x -> Just (x []))
|> Result.fromMaybe "failed to parse input"
_ ->
Err "failed to parse input"
This is a lot of code… way too much to write if I just want to type up my AoC algorithm quickly (such an overcomplicated first step to parse the input is just ridiculous).
Maybe there’s a better way to do this3. Please let me know.
H2 Stuff I didn’t like
The indentations and comma placements… I think it’s weird and I still don’t know how I’m supposed to indent things. For example,
this works:
tableHead = tr [] [
th [] [text "Reg A"],
th [] [text "Reg B"],
th [] [text "Reg C"],
th [] [text "Program"]
]
but this does not:
tableHead = tr [] [
th [] [text "Reg A"],
th [] [text "Reg B"],
th [] [text "Reg C"],
th [] [text "Program"]
]
neither does this:
tableHead = tr [] [
th [] [text "Reg A"],
th [] [text "Reg B"],
th [] [text "Reg C"],
th [] [text "Program"],
]
Also, this is a parse error:
type alias ProgramState = {
a: Int,
b: Int,
c: Int,
instrs: Array.Array Instr,
counter: Int,
console: List Int
}
but this is not:
type alias ProgramState = {
a: Int,
b: Int,
c: Int,
instrs: Array.Array Instr,
counter: Int,
console: List Int
}
There’s no labelled argument, which I imagine could be useful for building UI components that take lots of arguments.
I’m not allowed to shadow… extra time to come up with new names in order to have things compile :(
The name `state` is first defined here:
94| stepProgram state =
^^^^^
But then it is defined AGAIN over here:
97| let state = { state | counter = state.counter + 2 } in
^^^^^
Think of a more helpful name for one of them and you should be all set!
Note: Linters advise against shadowing, so Elm makes “best practices” the
default. Read <https://elm-lang.org/0.19.1/shadowing> for more details on this
choice.
I can’t write '
after variable name, so more trouble trying to name variables :(
H2 Part 2 caused some problems…
Uh oh, maybe Elm was a bad idea for part 2 😅
Something seems to be overflowing… I don’t know if this it had had to do with Elm’s representation of the Int
type. It might also be something that started happening at the compiled JavaScript level.
Anyway, it was fun learning it, and hopefully, it’ll end up being useful or at least inspirational. For now, I’ll set part 2 aside.
If I secretly did part 2 in Rust, you’ll never find out, right?
H2 Will I come back to Elm?
Maybe. If I ever need to quickly build a web ui someday, I’ll definitely consider it. There seem to be various Elm-inspired alternatives like Lustre that I might take a look at.
The Elm architecture, though, I won’t forget about it!