First impressions of Gleam: lots of joys and some rough edges

Monday, August 5, 2024

My friend Erika is a big fan of Gleam, and her enthusiasm (and explicit encouragement) finally got me to learn the language. It's a functional programming language which targets both the BEAM (Erlang's VM) and JavaScript. This makes it appealing as a language that can target both frontend and backend applications easily, can benefit from the large Erlang/Elixir and JavaScript ecosystems, and lets you use Erlang's fantastic scalability resiliency.

I've not used it in a real-world context yet (nor am I sure I'll ever have the opportunity), but going through the language tour gave me a lot of appreciation for Gleam. After going through it, I've got a list of things I definitely want to copy in the language I'm working on, Lilac—and a short list of things I do not want to repeat from Gleam (as a preference).

Overall first experience

Getting started with Gleam was a pretty good experience. The first thing did was install it locally, but you don't have to do that. The language tour itself runs Gleam in the browser, so you can learn the language without ever installing it locally!

I did install it locally for two reasons: I wanted to learn it in my usual programming environment with an LSP available; and I knew I'd need it installed to collaborate on a small Gleam project with Erika. Compared to Rust, it was a little bit harder to get installed, since you need at least three distinct toolchains (the gleam binary, Erlang/Elixir packages, and rebar3), but this was pretty well documented. It was just a small source of friction, but nothing out of the ordinary.

That last bit captures a lot of my experience with Gleam, to be honest. There are a number of things that have bits of friction or are surprising or not what I'd expect, but there's good documentation which gets you past all the sticking points. The docs are shockingly good for the age and size of the project. It was really easy to get started, and the language tour got me up and running with it far faster than I expected1.

The joyous parts

There is a lot to like in Gleam, and I'm looking forward to using it in some small collaborations. It's not something I'm bringing to work, but there are a lot of parts of it that I'm going to definitely carry forward into languages I design. And these are all things I'll look for in other languages I use, too.

The community is really welcoming and helpful. I joined the Gleam Discord well before I started writing Gleam to hang out with friends a bit, and they're so welcoming there! It's a really lovely community of really helpful people. You're not made to feel stupid for having questions, and you can chat directly with the people who make the language work. This community is obviously shaped by the care and love that the language's creator put into the community from the outset.

The pattern matching is a case study in how to do it. Gleam's pattern matching is so good, and well documented already. I'll give just a few examples here to avoid repeating the docs at length. A couple of the must-haves I really like are:

  • Exhaustiveness checking. If I do a pattern match and I'm missing a few values, the compiler will catch this! This is very useful for custom data types where you may miss a possibility.
    let message = case cores {
      0 -> "how does your computer have no cores?"
      1 -> "this is what, 1999?"
      2 -> "now we're getting somewhere"
    }
    
    The compiler will complain about this because if cores is anything other than 0, 1, or 2, it doesn't have a matching arm!
  • Structural pattern matching. You can pattern match on the contents of a string or a list or the structure of any data types you define. Here's a small example defining the max of a list of integers.
    pub fn listmax(xs) {
      case xs {
        [] -> 0
        [x, y] -> int.max(x, y)
        [x, ..rest] -> int.max(x, listmax(rest))
      }
    }
    
    This lets you write really concise and legible code.
  • Matching on multiple values. This is pretty common when you can define tuples, but it's also something not to take for granted. It's great to be able to match on multiple things with case x, y { ... }. If you're doing pattern matching, you gotta have this.

Shadowing makes immutability's ergonomics nice. This is something I like in Rust, and it's something I'm very happy to see in Gleam: immutable variables, but you can get safe faux-mutability by using shadowing! You can't even do something like x = 10 to reassign to something you've previously declared, you only have this style.

let x = 10
io.debug(x)
let x = 15
io.debug(x)

This is particularly helpful for modifying existing data structures (updating a field, adding to a list or a map, etc.) because you won't introduce race conditions but you can still keep good ergonomics from shadowing.

Note that I do prefer having the option of mutability, though, so this isn't a pure joy for me. Immutability feels a lot better when paired with shadowing, but there are some things that are a lot easier to express using mutation. And, as we'll talk about later, not having mutation means you can't have a useful loop construct!

There's a good LSP out of the box. Gleam was started in 2019, and the LSP was introduced in 2022. This lets you get Gleam support in just about every editor you're likely to use! Newer languages don't always have this, so it's great to see. Using it while learning Gleam just emphasizes to me how important it is to get this early. It helps significantly with adoption of the language, because it's easier to learn a language when the tooling can help you with it.

Qualified imports improve code discoverability. One of my biggest problems reading Rust code is that when you import a trait, you can't tell at the call site where the code is coming from. This is especially problematic in examples where imports may even be omitted, and you can't figure out where these methods came from. But it goes even further, with individual types and functions: when they're imported freely into the local namespace, at a certain point it just becomes confusing where they're coming from.

Gleam encourages qualified imports, and this greatly aids in discoverability2, which ultimately aid reading and understanding new codebases. While you can do unqualified imports, making qualified imports idiomatic means that most code ends up a little easier to learn from, which greatly helps people pick up codebases and the language.

Consider this (abridged) example from the Gleam tour:

import gleam/int
import gleam/io
import gleam/result

pub fn main() {
  // skipping most of the example

  int.parse("-1234")
  |> result.map(int.absolute_value)
  |> result.try(int.remainder(_, 42))
  |> io.debug
}

Even if I omitted the imports, you'd know that the map here is probably not the map function from the list module, and you'll know you need to understand it differently! In contrast, if the imports were unqualified, you'd just have:

parse("-1234")
|> map(absolute_value)
|> try(remainder(_, 42))
|> debug

And you'd be left with a lot more questions: parse what? which map? where's try from?

Labeled arguments clarify programmer intent. If you see this code, it's not very clear which number does what:

float.power(2.0, 3.0)

Because we're taught exponents in school, you can probably guess that this is 2 to the power of 3, but most things won't be that clear, and you're still guessing. With labeled arguments, you can make your intent clear:

float.power(2.0, of: 3.0)

Here you're raising 2 to the power of 3. This is especially clarifying in pipelines, where one argument is omitted:

2.0 |> power(of: 3.0)

The standard library is written in the language itself! This is wonderful because it means that practitioners of the language can read the code and understand it, where a lot of the Python standard library (for example) is written in C and is far less accessible to your average Python programmer. And it means that the language developers work on the language and standard library at the same time, so they get to feel the effects of any language change in a real Gleam codebase.

The standard library itself is also pretty nice. It isn't that big yet, but it includes a lot of the things you'd want to see: options, results, lists. Most of what you need for things like Advent of Code are included out of the box!

todo and panic as keywords make a lot of sense. Most languages I've used don't have any built-in todo affordance. My preferred language that does, Rust, has it as a macro (todo!), same with panic!. This is fine, but it feels good as a user of the language to have these as keywords. It means that we know they're an intentional part of the language design itself, and the compiler can do useful things with them. In particular, Gleam's compiler will give you a warning whenever you compile and your code contains todo, since that means it's not complete yet.

The rough edges

Of course, no language is without its quirks and drawbacks. I'm a big fan of Rust, and I have no shortage of things I don't like in it3. Gleam is no exception in this. I came away with quite a few things I am definitely not a fan of, where I won't want to replicate it elsewhere.

There aren't loops! This is intentional, since you can do everything through recursion to create looping behavior. It's also a necessity, given you don't have mutation: you can't really loop in most of the useful ways if you can't mutate a variable. And some people will accurately point out that you will usually use higher-level functions like list.map and list.fold most of the time, anyway, rather than explicitly recursing! This really falls flat for me when I look at a few examples, though.

The first one I'll look at is from the Gleam tour itself: factorial. The tour presents a nice, straightforward factorial implementation that is commonly used as an example when teaching recursion. But then they continue on and modify this to use tail calls so that it can be optimized, so you don't blow the call stack. We end up with this:

pub fn factorial(x: Int) -> Int {
  // The public function calls the private tail recursive function
  factorial_loop(x, 1)
}

fn factorial_loop(x: Int, accumulator: Int) -> Int {
  case x {
    0 -> accumulator
    1 -> accumulator

    // The last thing this function does is call itself
    // In the previous lesson the last thing it did was multiply two ints
    _ -> factorial_loop(x - 1, accumulator * x)
  }
}

In this example, we had to make a private function with a different interface to leverage tail call optimization (resulting in harder to read code), and that private function is very hard to understand compared to the usual imperative loop-based solution:

fn factorial(x: u64) -> u64 {
  let mut product = 1;
  for i in 1..=x {
    product *= i;
  }
  product
}

Note that I'm not advocating for avoiding the usual list, fold, etc. solutions—you would use those in Rust for this problem, just the same as in Gleam—but I wanted to use an example from the language tour itself to show that this very same problem is much harder to understand because of the lack of loops (and mutability).

This directly demonstrates a major problem of relying on recursion instead of loops: to achieve good performance with recursion, you end up sacrificing readability anyway.

A secondary problem with missing loops (and early returns) is that it's much harder to quit iteration in the middle of something. In Rust, when you're looping over something, you can quit as soon as you hit a failure case. That's not really doable when you call list.map in Gleam. You can get some similar behavior with Iterator, which is lazily evaluated, but there are performance costs to this as well (while Rust's iterators optimize to exactly the same binary as loops).

Type aliases lead to confusing/bad error messages. I've run into this problem with Rust, as well. In both Gleam and Rust, type aliases are simply different names to refer to the same underlying type. They don't change anything except what keys you press to get that type in your code.

The problem for me comes in when I try to assign to a variable where one of these aliases is used as the type. If I assign something that's the wrong type, the error message gives me the original type name instead of the alias name in the error message. This is sometimes confusing to me, because you can have a type show up seemingly out of nowhere, without anything in your code to tie it back to. Here's an example of code that will do this:

pub type UserId = Int

pub fn main() {
    let user_id: UserId = "1"
}

And it produces this error message:

  Compiling tour
error: Type mismatch
  ┌─ /home/nicole/Code/gleam/tour/src/tour.gleam:4:27
  │
4 │     let user_id: UserId = "1"
  │                           ^^^

Expected type:

    Int

Found type:

    String

I think having both types would be helpful, because as it stands I often run into confusion with this. It tells us it expected an Int, but I told it to expect a UserId! This is most problematic when the alias itself is defined in a library (not directly in my code) and I don't even realize it's an alias.

The differing number systems in JavaScript and the BEAM. One of the quirks of targeting multiple platforms is that each platform is different, and each one has its own quirks. While BEAM has some of these, JavaScript has far more.

In particular, you get JavaScript's numbers, which are, uh, well they're all just IEEE 754 floating point numbers, because why would you want anything else? This means that you can only have 53-bit integers if you target JavaScript, before things start behaving oddly. In contrast, when you target the BEAM, you get unbounded big integers!

On the other hand, the BEAM has its own warts. Overflowing a float raises an error, but Gleam doesn't have exception handling, so it just crashes out (instead of returning a result type). For example, this code will simply crash:

import gleam/float
import gleam/io

pub fn main() {
    io.debug(float.power(1000.0, 1000.0))
}

I would expect that since float.power returns a Result, if this fails it will return an error case, but since the standard library is implemented in Gleam itself and Gleam has no way to catch a runtime exception, you cannot do this.

So, when you work with numbers in Gleam, you're going to have to first work around the quirks of whichever target you're using, and second possibly work around the quirks of multiple targets.

The approach to parenthesization/grouping is clever, and that cleverness is not worth it. Each unusual choice you make in a language comes with a cost. And that's why I'm so nonplussed about the choice of using { and } for grouping in arithmetic expressions. In every other language I've used, you can use parentheses to do grouping: (1 + 2) * 3. But in Gleam, you have to group these with curly braces: { 1 + 2 } * 3. This is something I would really struggle to get used to, and I don't think I'm alone.

From a conversation I had with the language's creator, this one comes from how Gleam doesn't use statement/expression terminators. In many languages, you either detect whitespace to end a statement, or you look for specific punctuation (usually the semicolon). Gleam's grammar doesn't require this. For reasons I don't entirely understand, that does mean that using parentheses for grouping would be hard and would change how it's parsed.

So instead, we get this! And I think it's a little bit clever4: The language is expression-oriented and blocks return values, so if you do { 1 + 2 } it's already going to return a number. So this block orientation already exists, and we can use it to group things as well without fundamental changes to the language. And I think that ultimately it's a mistake for ergonomics, because it will be rather different (to write and to read) from what most people are familiar with.

Go learn some Gleam!

Gleam is a wonderful little language and community. I hope it continues to grow and that it thrives. Learning it has given me a lot of ideas that I want to carry forward, and it's given me yet another language I can use to solve problems. If you have a free afternoon, you should try it out!


Thank you to Louis Pilfold and Erika Rowland for feedback on a draft of this post!


1

I started poking away at the language tour almost a month ago. It only actually takes a few hours to read through, probably. It's really fast! But I've been sick, and it took me most of a month to read it, take some notes, and write this up.

2

This point is very well made in Erika's post about Gleam's best features, which is another good read.

3

That's one of the reasons I'm working on my language, Lilac. I want to achieve two things. One is to make a language that has a lot of what I like from Rust (and Gleam!) without some of the things I find hard to use. The other, though, is to gain a deeper understanding of why some of the things I don't like are done the way they are. It's easier to use a tool when you understand why it is the way it is.

4

This is also true for the use keyword. The way I feel about use in Gleam is: this is very clever, and it's a problem that didn't have to exist in the language. It avoids expanding the language somewhat, but is hard to understand and lacks some of the expressive power of adding other language constructs.


If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts and support my work, subscribe to the newsletter. There is also an RSS feed.

Want to become a better programmer? Join the Recurse Center!
Want to hire great programmers? Hire via Recurse Center!