Back Original

What TypeScript & Elixir Can Learn from each Other (Advent of Code 2024)

The Advent of Code is a fun annual programming competition with an Elf theme. It consists of 25 two-part problems of increasing difficulty, released every day in December leading up to Christmas.

Every December, I complete it in a new programming language. Every January, I intend to blog about the experience, but inevitably it slips, this year all the way back to November! (Sorry, I got completely consumed by a non-TypeScript side project.)

In 2024, I did the Advent of Code in Elixir, a functional programming language with immutable data types based on Erlang. This is analogous to how the JVM was built for Java, but also serves as a runtime for other languages like Kotlin and Scala.

I picked Elixir largely because a friend of mine worked in it. I'd also never written a substantial amount of code in a purely-functional language, and I was curious to see how it worked.

I suspect there's relatively little overlap between TypeScript and Elixir developers. They serve different roles and occupy different niches. But they do have a thing or two in common. In particular, Elixir is in the process of adding a gradual, optional typing system. Sound familiar?

  1. A quick intro to Elixir
  2. What can TypeScript learn from Elixir?
  3. What can Elixir learn from TypeScript?
    1. Prioritize Language Services
    2. Adopting Types
  4. General Impressions of Elixir
  5. Thoughts on the 2024 Advent of Code
  6. Conclusions

Here are the previous installments in this series:

A quick intro to Elixir

Here's an Elixir function to read lines from a file (pretty helpful for Advent of Code problems!):

def read_lines(file) do

Here you can see Elixir's pipeline operator (|>). This has been a hot topic in the JavaScript world for nearly a decade, so it was fun for me to get to play around with it. It wasn't as useful as I expected (more on this below).

Here's another pair of functions to "chunk" a list of strings into groups delineated by blanks (also very helpful in Advent of Code):

Here you can see more uses of the pipeline operator, a helper method from the ubiquitous Enum module, pattern matching on tuples, and an anonymous function (&(&1 != "")). In TypeScript, we might write this function in a more imperative style as:

function splitOnBlanks(lines: readonly string[]): string[][] {

Here's one final Elixir snippet that reads a grid into an (x, y) -> char map and calculates its width and height. Again, this is very handy for Advent of Code problems!

def read_grid_from_lines(lines) do

Here you can see Elixir's list comprehensions (for), which are a flexible way to build data structures. The into: %{} clause in the first comprehension says to put the results into a Map (%{} is an empty Map). These comprehensions all do pattern matching on the enumerable (what JavaScript would call an iterable). The last two use the pipeline operator to extract the max.

That gives you some of the flavor of Elixir. You can read much more about it on the official docs.

What can TypeScript learn from Elixir?

There are many long-stalled proposals to extend JavaScript, perhaps none more famous than the pipeline proposal, which has been around in some form since at least 2015.

At first blush, the pipeline proposal is simple and uncontroversial. Instead of writing nested function application as:

console.log(Object.keys(getUserPreferences(getCurrentUser())))

You'd be able to write it as:

getCurrentUser()

The beauty of this is that the order of the code reflects the order of execution (top-down here, rather than right-to-left) and there's much less nesting.

You can simulate a pipe in a few ways in JavaScript today. One approach is to repeatedly assign to a temporary variable:

let t;

This eliminates the nesting and inside-out order, but I can already see TypeScript users grimacing. While this works in JavaScript, it typically won't in TypeScript, where a symbol's type can only change in a very specific set of ways. Instead, you'd need to introduce a new variable for every assignment (static single assignment form).

Functional libraries typically offer pipelines via a wrapper object, notably jQuery and lodash:

console.log(_(obj).meth1().meth2().value)

These are limited to methods provided by the library, though. Lodash's chains can't help us with the getUserPreferences example. It would be much better if pipes were built into the language itself.

Why has the pipeline proposal has been stalled for so long? One reason is that there are different ideas about whether to offer special syntax for calling a function in a chain, and how that should work. This has always seemed like a secondary concern to me. Why not just add a basic pipe operator and worry about syntactic sugar later? Elixir has built-in pipes, so I was excited to see how they worked in practice.

The TL;DR is that pipes alone aren't all they're cracked up to be. I understand now why TC39 is so hung up on syntax extensions. It was incredibly rare that I used a function in a pipe without having to pass other arguments or adapt it in some way.

Elixir offers a few shorthands to facilitate working with pipes. For example:

f(x)      

This is stranger than it looks. When you write x |> f(), Elixir doesn't call f with zero arguments. Instead, it rewrites this expression to pass x as the first parameter to f (I assume using macros). This winds up feeling pretty natural when you use constructs like map:

[1, 2, 3, 4]

The Enum module contains many general functions for working with collections. These functions all take the collection as their first argument, which is conducive to piping. (I found this Enum cheatsheet extremely helpful.)

This example includes two ways of writing anonymous functions. &1 is the first argument to the function. This is convenient for writing very short functions. (You write the identity & &1.)

What would this look like with a plain vanilla JavaScript pipe? You'd have to create lots of wrapper functions:

[1, 2, 3, 4]

(this example looks silly since map and filter are already methods on Array)

This adds boilerplate and has been flagged as a performance concern by browser vendors. The proposal suggests introducing a new, concise syntax for creating anonymous functions using a placeholder symbol (perhaps %):

[1, 2, 3, 4]

This at least makes the pipe more concise.

I enjoyed using Elixir pipes. (I would have enjoyed using them more with better typing!) But as I solved more AoC problems in Elixir, I found myself using them less and less. Instead, I starting using more and more comprehensions.

for(

Comprehensions let you combine multiple inputs, reduce your results and put them into any sort of collection you want. You can read more about them in this Comprehensive Guide. Perhaps it's because I'm comfortable with Python comprehensions or due to the Advent of Code problems themselves, but this was usually what I wanted. (One gotcha: a when clause in a comprehension is weirdly restricted. I understand this has something to do with Erlang and it's been explained to me a few times, but it never really clicked what I could and couldn't do.)

In conclusion: adding simple pipes to JavaScript probably wouldn't be that useful because you'd have to define so many small wrapper functions. If you want to fix that, too, you can understand why the proposal has gotten bogged down. At the end of the day, what I really want is comprehensions.

I'm not aware of any active proposals to add comprehensions to JavaScript. Interestingly, Firefox used to have its own non-standard comprehensions. There was discussion about adding this to what became ES2015, but it was deferred to a future standard in 2014 and seems to have died there.

What can Elixir learn from TypeScript?

Prioritize Language Services

When you install TypeScript, you get two binaries:

tsc is typically the only one you invoke directly, but you interact with tsserver more because it's what powers your editor. The TypeScript team treats the language service as a first-class citizen. It's just as important as the compiler or the language itself.

I didn't get the sense that Elixir Language Server got nearly so much attention. Three quick examples will illustrate the point.

First, Elixir has two modes, "scripting mode" (.exs file) and regular mode (.ex files). I started off in "scripting mode" for simplicity, but wasn't getting any editor support. Sure enough, this just isn't supported.

Second, syntax errors make the entire document flash red. I found this incredibly irritating. It often happens if you've just typed the "en" in end, say:

Third, there are basic features you expect from an IDE that simply don't work with Elixir. Rename support is high on the list.

Writing a great language service is no easy task. But the TypeScript experience is that it's critical to invest in it, because it determines how it feels to use the language.

Adopting Types

Side effects and mutation are notoriously difficult to model in a static type system. (Chapter Three of Effective TypeScript is all about this.) Elixir is purely functional (no side effects) and has exclusively immutable data structures. This makes writing code harder (at least for me!) but it should make static type analysis easier.

And yet, despite being fertile ground for static type analysis, Elixir has, historically, been untyped. So I was excited to learn that they're introducing an optional, gradual type system to the language. This should sound familiar to TypeScript users: TS pulled the same trick with JavaScript.

My first experiments with the new type system were deeply confusing. The new type system is, indeed, quite new. But if you search online for "elixir type system," you'll find lots of material about how to use it. This only cleared up for me when I realized that Elixir has two type systems: the old one (Dialyzer) and the new one (gradual set-theoretical types).

There's a paper (arXiv) describing the high-level goals and workings of the new type system, as well a video introducing it.

Set-theoretical type checking in Elixir seems very much a work-in-progress at this point. For example, you do get some errors in your editor:

def bad_add() do

Type error for bad_add

But you get more errors when you compile from the command line. I never got type errors in function chains, where it's sometimes hard to remember if you're working with a list or list of lists. And I was constantly making errors in how I read from maps with tuple keys:

def read_tuple_from_map() do

The bad example does not produce a type error, at least not with Elixir 1.19.

It looks like the upcoming Elixir 1.20 release will add inference for whole functions:

def add_foo_and_bar(data) do

Elixir now infers that the function expects a map as first argument, and the map must have the keys .foo and .bar whose values are either integer() or float(). The return type will be either integer() or float().

This is quite different than the sort of type inference that TypeScript does. TypeScript will infer a function's return type, but it never infers a parameter's type from the way that it's used in the function's body. (TypeScript will infer a parameter's type if it knows the function's type from context, for example if it's used as a callback.)

It's appealing to write fewer types, so why doesn't TypeScript do this sort of inference? The problem is that an implementation error in the function body can "leak" out into its signature, which will produce errors at call sites. Anders Hejlsberg, the creator of TypeScript, calls this "spooky action at a distance." For example, if you had a typo in add_foo_and_bar, say you accessed .baz:

def add_foo_and_bar(data) do

then this would change the inferred type signature and you'd get errors at all your call sites. But the mistake is in the function body, not the call sites! The idea behind requiring type annotations for parameters is that you should know these types when you write your function. By writing explicit annotations, you make it clear to TypeScript whether the error is in the implementation or the caller.

We'll see how much of an issue this winds up being for Elixir. I suspect TypeScript's policy would be hard for Elixir to adopt in practice because its functional style encourages you to write lots of small, standalone helper functions that might be boilerplatey to type explicitly.

(See this comment from Ryan Cavanaugh for more on why TypeScript doesn't infer types for function parameters.)

General Impressions of Elixir

AoC isn’t a very good showcase for Elixir — it’s most well-known for concurrency, which is completely irrelevant for Advent of Code problems. Overall I didn’t dislike Elixir as much as I thought I might after the first week, but I never really came to like it that much, either.

Thoughts on the 2024 Advent of Code

This was the first year where AI assistants like GitHub Copilot were relevant to the competition. I think Eric made some of the problem statements more elaborate than in the past to try and throw them off. I doubt it worked. Some of the winning times were suspiciously fast.

I didn't find GitHub Copilot particularly helpful for the 2024 Advent of Code problems. I did a few of the 2016 problems as a warm-up, and it was wildly helpful there. Sometimes you'd just start writing some boilerplate and Copilot autocomplete would fill in the entire solution! This makes sense if you think about it: the LLM's training data includes thousands of solutions to old Advent of Code problems in many languages, including Elixir.

The difficulty level in 2024 didn't feel too high. For me, 2019 remains the most difficult Advent of Code. As usual, building some tools for working with grids and doing BFS / A* search is helpful.

Standout puzzles for me included:

I didn't like day 20, the number we were computed felt very contrived and that threw me off. Days 22 and 23 were surprisingly easy. I had trouble with days 9 and 17 and wound up implementing solutions in Python.

Just like 2019, I was traveling for much of December and internet connectivity was sometimes an issue. Some of the lodges we stayed in had Starlink in the dining area, so I'd load the Advent of Code problem on my phone and Airdrop my input to my laptop back in our room. I queued up five or six problems to work on during my flight back and submitted all my answers on my phone when we landed.

Conclusions

Overall I enjoyed the 2024 Advent of Code. I wasn't a big fan of Elixir, though I understand that Advent of Code doesn't play to its strengths. Solving problems in a functional language wasn't as painful as I expected, so I might try it again next year. Speaking of which, the 2025 Advent of Code is going to be quite different!

You can find my code at danvk/aoc2024 (all days) and danvk/aoc2016 (days 1-11).