zettelkasten

Search IconIcon to open search
Dark ModeDark Mode

Exploring Elm: AoC Day 17 + Declarative Web UI in a Functional Language

Date: 20 Dec 2024

#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 msgs 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:

elm ui example.jpg

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!


  1. not the best idea ↩︎

  2. (bolded red opcode indicates where the program counter is) ↩︎

  3. It looks like the only way to have runtime exception is by calling todo : String -> a, but there’s no way to catch it. ↩︎