Advent of Code in Gleam
| 6 minutes read
Every year, I try to use a new programming language while tackling Advent of Code. If you aren’t familiar, it’s a series of small coding challenges that starts on December 1st and runs for 25 days (though this year I managed 12). You get two tasks every day, ranging from simple logic to complex algorithms.
Last year, I started with Elixir. However, at a certain point, I moved over to Python. While I loved Elixir—it’s just a fun, expressive language—it became quite slow with larger inputs. For specific math-heavy problems, I found the same algorithm was up to 100x faster in Python. Pure math isn’t exactly where Elixir (or Gleam) excels compared to optimized C-extensions in Python.
Despite the performance gap, I had so much fun with Elixir that I decided to give Gleam a shot this year. I’ve wanted to learn Gleam for a long time, and AoC provided the perfect excuse.
The Compiler is Your Friend
My first hurdle was adapting to a compiled, statically typed language. Elixir feels “easier” initially because it is dynamic; you don’t have to worry about types. But in Gleam, the compiler is a constant companion.
Take a look at how we might parse a line of input. Here is an idiomatic Elixir version compared to the Gleam version I wrote.
Elixir Version
In Elixir, we use atoms and tuples, but we don’t have a compiler checking if we’ve handled every case.
def parse_line(line, index) do
line
|> String.split("", trim: true)
|> Enum.with_index()
|> Enum.flat_map(fn {char, curr_index} ->
case char do
"@" -> [{index, curr_index}]
_ -> []
end
end)
end
Gleam Version
In Gleam, the types are explicit. While they take more effort to type out, they provide a much higher level of confidence.
pub fn parse_line(line: String, index: Int) -> List(#(Int, Int)) {
line
|> string.split("")
|> list.index_map(fn(el, curr_index) {
case el {
"@" -> option.Some(#(index, curr_index))
_ -> option.None
}
})
|> list.filter_map(fn(el) {
case el {
option.Some(a) -> Ok(a)
option.None -> Error(Nil)
}
})
}
At first, the extra typing in Gleam felt a bit annoying. However, I quickly realized that types don’t “cost” you time—they save it. Much like in Elm, defining types helps me build a mental model. I start by asking: “What comes in, and what goes out?” For parse_line, I know I get a String and an Int, and I want to return a List of tuples. Writing the signature fn(String, Int) -> List(#(Int, Int)) creates a contract. The Language Server (LSP) can then provide much better assistance than in Elixir, where type annotations (@spec) can “rot” or become outdated if you forget to update them.
Safety and Pattern Matching
One of the biggest wins for Gleam is how it handles enums (Custom Types) and pattern matching. In Elixir, if you add a new atom to a logic flow, you won’t know you’ve broken something until the code crashes at runtime.
In Gleam, the compiler exhaustively checks your case statements.
type Message {
Connect
Disconnect
}
fn handle(msg: Message) -> String {
case msg {
Connect -> "Connected"
// The compiler will ERROR here because Disconnect is not handled!
}
}
In Elixir, the equivalent would look like this, but it would only fail when a :disconnect message actually hits that function:
def handle(msg) do
case msg do
:connect -> "Connected"
# :disconnect is missing, causing a CaseClauseError at runtime.
end
end
The Learning Curve: OTP and Supervision
One area where Elixir still feels more “sophisticated” is the Actor model and OTP. In Elixir, GenServer and the supervision trees are the core of the language. It feels very natural to send a message to a process by name and have it restart on failure.
In Gleam, things are more explicit. To send a message to an actor, you must have a reference to its Subject. While this provides type safety (you know exactly what kind of messages an actor accepts), it was harder for me to wrap my head around the supervision model.
I recently tried to rewrite a Java service using Gleam. I struggled because I was dealing with specific enterprise tools like Apache Artemis (ActiveMQ) and MySQL. Elixir has decent support for these, but Gleam’s ecosystem is still growing. I realized then that Gleam isn’t a “silver bullet”—it’s an amazing tool, but you have to choose it for the right reasons.
Why I Love Functional Programming Gleam forces you to rethink your algorithms. For example, there is no simple list[5] index access. Because Gleam uses linked lists, index access is O(n). By making “easy” (but inefficient) things hard, the language encourages you to use pattern matching and recursion, which often leads to cleaner logic.
Are FP languages like Gleam, Elixir, or Elm “superior”? Maybe not in raw speed—Java is incredibly fast. But I prefer the constraints. Java gives you a thousand ways to do the same thing, which often leads to a “hell” of interfaces, abstract classes, and builder factories.
Gleam is pure, simple, and side-effect free. It’s easier to write messy code if the language allows it; Gleam makes it much harder to be messy.
Example: Simple Supervision in Gleam Below is the simplest way I found to send messages between supervised actors in Gleam. This pattern ensures that if the “Sender” crashes, the supervisor will restart it.
import gleam/io
import gleam/int
import gleam/otp/actor
import gleam/otp/static_supervisor
import gleam/otp/supervision
import gleam/erlang/process
pub type ReceiverMessage {
Print(Int)
}
fn start_receiver_actor() {
actor.new(process.new_subject())
|> actor.on_message(fn(state, msg: ReceiverMessage) {
case msg {
Print(number) -> {
io.println("Receiver Actor got: " <> int.to_string(number))
actor.continue(state)
}
}
})
|> actor.start
}
pub type SenderMessage {
Tick
}
fn start_sender_actor(receiver: process.Subject(ReceiverMessage)) {
actor.new_with_initialiser(1000, fn(self: process.Subject(SenderMessage)) {
process.send(self, Tick)
Ok(actor.initialised(#(receiver, 0, self)))
})
|> actor.on_message(fn(state, _msg: SenderMessage) {
let #(receiver, count, self) = state
let next_count = count + 1
io.println("Sender Actor sending: " <> int.to_string(next_count))
// Crash when trying to send 4 to demonstrate supervision
case next_count {
4 -> panic as "Intentional crash on number 4!"
_ -> {
process.send(receiver, Print(next_count))
process.send_after(self, 1000, Tick)
actor.continue(#(receiver, next_count, self))
}
}
})
|> actor.start
}
pub fn main() {
io.println("Starting supervisor with one-for-one strategy...")
let assert Ok(receiver_actor) = start_receiver_actor()
let receiver_subject = receiver_actor.data
let assert Ok(_sup) =
static_supervisor.new(static_supervisor.OneForOne)
|> static_supervisor.add(supervision.worker(fn() { Ok(receiver_actor) }))
|> static_supervisor.add(supervision.worker(fn() {
start_sender_actor(receiver_subject)
}))
|> static_supervisor.start()
process.sleep_forever()
}
Advent of Code is about more than just solving puzzles—it’s about broadening your horizons. Whether you’re using Gleam, Elixir, or Kotlin, the goal is to think differently. I’m happy to be challenged and continue learning!