I recently took some time to try Gleam, the type-safe language that runs on the Erlang virtual machine. For a couple of weeks, I used it to build a little open-source feed aggregator that’s now live. These are my notes.
why I’m interested in Gleam
I spent most of my career working with dynamic languages: Python, JavaScript, Clojure, finally Erlang. I don’t claim to be an expert but the Erlang VM is the piece of technology I’ve become most intimately familiar with. There is no way around it, I think: if you’re running Erlang in production sooner or later you’ll be thinking in terms of processes, scheduling, failure modules, resource utilisation1.
After Erlang, I dabbled in Rust, both professionally and on personal projects, and I was very impressed by its type system, its error handling, and the developer experience it enabled. The thing is: Rust comes with all the systems programming overhead, which in my opinion is not a good bargain if you are not, you know, programming systems. Alas, I’m too old to pick up my own garbage. And, coming from Erlang, Rust’s async concurrency (like JavaScript’s, like Python’s) just makes me very sad inside.
By now you may imagine why Gleam looks interesting to me:
- I would like a Rust minus the memory management and with a gentler concurrency model;
- I would like a BEAM language without Erlang’s rough edges and with the goodies of a modern type system2.
I’m not claiming Gleam is one of those things, but it seems close enough to spike my interest.
the project
After happily running my personal feed reader for over a year, several spin-off projects have been coming to mind: turning my list of feeds into a blog roll, my list of favorites into a link blog, making a public version of my feed, etc.
The single remarkable feature of my reader is that I don’t sort posts chronologically but by “reverse frequency”. If I follow a news site that publishes twice an hour and an essayist that publishes every two months, I want the essayist at the top of my feed if they published yesterday, even if there are 30 more recent news posts3. To get this effect I distribute the feeds in frequency buckets and sort by reverse frequency first, publish date later. This is what the bucket calculation looks like in Gleam4:
/// Calculate the frequency bucket of the feed,
/// by checking the average post frequency from the current
/// entry list. The higher rate the higher bucket.
fn calc_bucket(entries: List(FeedEntry)) -> Int {
let by_date =
list.sort(entries, by: fn(e1, e2) {
birl.compare(e1.published, e2.published)
})
case list.first(by_date), list.last(by_date) {
Ok(first), Ok(last) -> {
let delta = birl.difference(last.published, first.published)
let days = int.max(1, duration.blur_to(delta, duration.Day))
let posts_per_day =
int.to_float(list.length(entries)) /. int.to_float(days)
case posts_per_day {
// once a month or less
n if n <=. 1.0 /. 30.0 -> 0
// once week or less
n if n <=. 1.0 /. 7.0 -> 1
// once a day or less
n if n <=. 1.0 -> 2
// 5 times a day or less
n if n <=. 5.0 -> 3
// 20 times a day or less
n if n <=. 20.0 -> 4
// more
_ -> 5
}
}
_, _ -> 0
}
}
The minimal feed reader spin-off I could think of was then to:
- dump my followed feeds into a file,
- write a program that periodically pulls entries from each feed on the list,
- sorts them in reverse frequency,
- serves them as a list of links.
the design
The approach I took to design the app was: try to do what you would do in Erlang and see if that fits in Gleam. I came up with this:
- A set of gen_servers, one per feed, that would periodically request the feed URL, parse the entries, and store them in the process state.
- Another gen_server that would periodically query each feed poller process for its most recent list of entries, sort them, and dump them into a globally accessible ETS table. (That way, no process becomes a source of contention between clients trying to read the data, and the rebuilding of the table doesn’t delay its reading).
- A minimal web server that would just query the table and build an HTML list of the current entries.
I wouldn’t bother with persisting the entries to a data store, although I would keep a file cache of XML responses to rebuild the state on app restarts without spamming the source sites.
The final result was very close to this initial design, with the exception that I opted for a persistent_term instead of an ETS table, since I didn’t want to get entries by key but read the full list on every request.
env setup
As a digression, I want to echo something I read recently: this is a great time to be an Emacs user. Thanks to LSP, tree-sitter, and apheleia, I get a very consistent UX for new languages practically out of the box, something that a couple of years ago required days of tweaking to get right5.
I don’t know if it’s a particular inclination of the language designer or that’s just how things are done today, but I was surprised to see how much emphasis is put on improving the developer experience and, specifically, adding features to the Language Server implementation. During my first week of development, I constantly missed the ability to lsp-rename I have in other languages; during my second week, a new Gleam version came out adding that very feature!
learning
- I learned most of what I needed to know in 30 minutes, through the official tour. I had the advantage of being already familiar with Rust and Erlang, but I would say that the fact that everything can be covered so succinctly speaks of the effort that went into keeping the language small and simple.
- For the standard libraries and external dependencies, their respective hex docs pages are the way to go.
- To pick up on conceptual discussions, ask questions, and learn what’s idiomatic, I found that (unfortunately for me) the discord channel is more effective than searching GitHub.
the language
- The language specification is already stable, having reached 1.0 about a year ago.
- The syntax feels like a functional subset of Rust, with Elixir-like pipes (
|>) and a standard library adjusted to take advantage of them. -
One of the things I pay more attention to in a programming language, and one that can be a good indicator of its “vibe”, is how it deals with imports and namespaces. I think Gleam gets this exactly right6, with namespaces matching modules matching filenames, a single directive for imports, and qualified imports as the easy default:
import gleam/io import gleam/string as text pub fn main() { // Use a function from the `gleam/io` module io.println("Hello, Mike!") // Use a function from the `gleam/string` module io.println(text.reverse("Hello, Joe!")) }Qualified imports are the best default in my opinion, but they can be redundant when importing types. Gleam has that covered too:
import gleam/bytes_tree import gleam/string_tree.{type StringTree} pub fn main() { // Referring to a type in a qualified way let _bytes: bytes_tree.BytesTree = bytes_tree.new() // Refering to a type in an unqualified way let _text: StringTree = string_tree.new() } -
There is no magic: no macros, no meta-programming, no traits, no default or rest arguments. This is usually fine for me, except when printing values for debugging, which becomes very tedious since everything needs to be coerced to a string and manually concatenated:
io.println( "ERROR requesting " <> feed.url <> "\n" <> string.inspect(resp.status) <> " " <> string.inspect(error), )
error handling
Error handling is always a contentious topic, especially in languages that have errors as values. There’s usually some language support to ease the burden of checking results: Elixir has with expressions, Rust has the question mark operator, Go has, uh, if statements. The Gleam equivalent to those is the use <- expression7.
use is perhaps the only “special” bit of syntax of the language, and the one that requires more effort to get used to. One way to think about use is that it “absorbs” the callback argument of a function much like the pipe “absorbs” the first argument of an operation. For example:
pub fn handle_request(request: HttpRequest) {
logger.span("handle_request", fn() {
database.connection(fn(conn) {
case request.method {
Post ->
case database.insert(conn, request.body) {
Ok(record) -> created_response(record)
Error(exc) -> bad_request_response(exc)
}
_ -> method_not_allowed_response()
}
})
})
}
Becomes:
pub fn handle_request(request: HttpRequest) {
use <- logger.span("handle_request")
use <- require_method(request, Post)
use conn <- database.connection()
case database.insert(conn, request.body) {
Ok(record) -> created_response(record)
Error(exc) -> bad_request_response(exc)
}
}
I admit I haven’t my got a-ha! moment with use yet, and I still struggle with error handling. Part of the problem, I think, is that use helps with callbacks, which are much less frequent than Result values, so function calls typically need to be wrapped in result.try and, since different functions tend to return different error types, this occasionally needs to be paired with result.replace_error to make it work:
use resp <- result.try(
httpc.configure()
|> httpc.follow_redirects(True)
|> httpc.dispatch(req)
|> result.replace_error(RequestError),
)
io.println(resp.body)
One of the patterns that emerges from this, I believe, is to define an app-specific error type and use it everywhere, mapping external errors to it.
erlang interop
The overall impression I got is that, compared to Elixir, Gleam is more distanced from Erlang. This in part a necessity, since they are fundamentally different languages: type safety is not a straight fit to the BEAM. But I also sense an intention in Gleam’s design to “make sense” on its own, to have conceptual integrity independently from its target platform (Gleam compiles to JavaScript in addition to Erlang). There is no direct mapping for some of the Erlang types, no REPL, no discussion of concurrency in the base documentation (not even in the section targeted to Erlang users). Erlang processes and OTP seem more like add-on libraries than part of the language foundation.
Interop is straightforward, just declaring a function and its Erlang counterpart, with some type specs:
@external(erlang, "persistent_term", "put")
fn put_entries(key: String, value: List(Entry)) -> atom.Atom
@external(erlang, "persistent_term", "get")
fn get_entries(key: String) -> List(Entry)
Things got tricky for me when I wanted to use erlsom, a quirky Erlang library to parse XML documents. For one, I had to use atom.create_from_string and charlist.to_string pervasively to interface with Gleam, which was a minor inconvenience. What was more of a problem is that, with XML docs being structurally free form, parsing an Atom feed yielded a different data structure than parsing an RSS feed, and that didn’t make sense to Gleam’s type checker.
My initial implementation hacked away this problem by parsing the document multiple times to “fool” the compiler—once to figure out what type of feed the document was, another to extract the entry data. The proper Gleam way of treating dynamic structures like these would be through the dynamic/decode module, but I found that too complicated for my purposes. After looking at other libraries, I realized that the right solution was to write a thin Erlang FFI module to normalize the data before passing it to Gleam. So I put together a very basic feed parser that extracts the few fields I needed into an Erlang map:
-module(parser).
-export([parse_feed/1]).
parse_feed(Body) ->
Result = erlsom:simple_form(
Body,
[{nameFun, fun(Name, _,_) ->
unicode:characters_to_binary(Name)
end }]
),
try Result of
{ok, {<<"rss">>, _, [{_, _, Elements}|_]}, _} ->
{<<"rss">>, parse_rss(Elements)};
{ok, {<<"feed">>, _, Elements}, _} ->
{<<"atom">>, parse_atom(Elements)};
Error ->
{<<"error">>, Error}
catch _:_ ->
{<<"error">>, bad_parse}
end.
parse_atom(Elements) ->
lists:foldl(fun({<<"entry">>, _, Attrs}, Acc) ->
[parse_atom_entry(Attrs, #{}) |Acc];
(_, Acc) -> Acc
end, [], Elements).
parse_rss(Elements) ->
lists:foldl(fun({<<"item">>, _, Attrs}, Acc) ->
[parse_rss_entry(Attrs, #{}) |Acc];
(_, Acc) -> Acc
end, [], Elements).
parse_atom_entry(Attrs, Acc) ->
% ...
parse_rss_entry(Attrs, Acc) ->
% ...
otp
OTP in Gleam is what took me the most effort to figure out. The hexdocs cover the basics but that wasn’t enough for me to get the concepts right, especially where they differed from their Erlang counterparts. For that, I reached out to this GitHub project, its tests and those of the gleam/otp repo, and the Gleam Discord history.
The first big difference is that, in Gleam, you typically don’t pass around process ids to send messages to; instead, a process “declares” what type of messages it expects to receive by creating a Subject:
let subject = new_subject()
// Send a message with the subject
send(subject, "Hello, Joe!")
// Receive the message
receive(subject, within: 10)
Creating a subject is akin to opening a channel in other languages8.
Therefore, when you create an actor—Gleam’s equivalent of a gen_server—what you get is not a Pid but a Subject. The basic boilerplate for a server, in this case the one managing the entry table, looks like this:
// type alias for convenience
pub type Table = Subject(Message)
// Declare what types of messages this actor is going to receive
pub type Message {
// send a message to itself to rebuild the table
// the subject is passed again for scheduling the next message
Rebuild(Table)
// save a new feed poller to the internal state
// the poller (another subject) is passed to request entries
// during table building
RegisterFeed(String, Poller)
}
// Declare the shape of the server's internal state
type State {
State(feeds: dict.Dict(String, Poller))
}
pub fn start() -> Table {
let state = State(dict.new())
let assert Ok(table) = actor.start(state, handle_message)
put_entries(table_key, [])
process.send(table, Rebuild(table))
table
}
The loop function just deals with the different Message variants:
fn handle_message(message: Message, state: State) {
let state = case message {
RegisterFeed(name, poller) -> {
State(dict.insert(state.feeds, name, poller))
}
Rebuild(self) -> {
let entries = latest_entries(dict.values(state.feeds))
put_entries(table_key, entries)
process.send_after(self, rebuild_interval, Rebuild(self))
state
}
}
actor.continue(state)
}
The module’s public API has a function to register a feed in the table and another to get the latest entries:
/// Add a poller to the table manager process
/// so its entries are included when refreshing the table.
pub fn register(table: Table, name: String, poller: Poller) {
process.send(table, RegisterFeed(name, poller))
}
/// Return the current list of entries.
pub fn get() -> List(FeedEntry) {
get_entries(table_key) |> list.map(fn(e) { e.entry })
}
The latter doesn’t need to receive a table (i.e. a Subject(Message)) because the entries are stored in a globally accessible persistent term.
Unlike the rest of the language, the OTP abstractions haven’t stabilized yet. In other words, gleam_otp hasn’t reached 1.0. This was most evident with Supervisors. They come in two flavors: the older otp/supervisor, which is discouraged and has a few bugs, and the newer otp/static_supervisor, which works better but is less flexible. I went with a static supervisor for my project. The supervision tree looks like this:
table_sup
├── table_worker
└── poller_sup
├── feed_poller_worker
├── feed_poller_worker
└── ...
I couldn’t find a way to either pass the pollers to the table or the table to the pollers while still having every actor spawned by its supervisor. And I couldn’t work around it by making the table a named process, since I needed a Subject, not a process, to send messages to. So I hacked it by storing the Subject on another persistent term every time a new Table actor starts.
deployment
The documentation always uses gleam run to run a program, but what if I want to deploy a release to a server without installing Gleam in it? Some digging revealed there’s a gleam export erlang-shipment command that will build the project with Erlang modules in production mode, and with an entrypoint script to run it (provided erl is in the path):
run() {
erl \
-pa "$BASE"/*/ebin \
-eval "$PACKAGE@@main:run($PACKAGE)" \
-noshell \
-extra "$@"
}
shell() {
erl -pa "$BASE"/*/ebin
}
A neat trick is to change this script to make the shell command attach to a running node:
run() {
erl \
-pa "$BASE"/*/ebin \
-eval "$PACKAGE@@main:run($PACKAGE)" \
-noshell \
+ -name news@127.0.0.1 \
-extra "$@"
}
shell() {
- erl -pa "$BASE"/*/ebin
+ erl -pa "$BASE"/*/ebin -name sh@127.0.0.1 -remsh news@127.0.0.1
}
That way one can interact with the (Erlang compiled) Gleam modules:
$ build/erlang-shipment/entrypoint.sh shell
Erlang/OTP 27 [erts-15.0] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit]
Eshell V15.0 (press Ctrl+G to abort, type help(). for help)
(news@127.0.0.1)1> table:get().
[{entry,<<"Copy first, create later">>,
<<"https://resextensa.co/p/copy-first-create-later">>,
{time,1740158441000000,0,none,none}},
{entry,<<"The CRPG Renaissance, Part 3: TSR is Dead"...>>,
<<"https://filfre.net/2025/02/the-crpg-renaissance-part-3-tsr-is-dead">>,
{time,1740157523000000,0,none,none}},
{entry,...},
{...}|...]
I briefly documented the rest of my deployment setup here.
thoughts
I’m not sure if the designer or the community would agree but, to me, Gleam’s killer feature—the reason I would choose it over other languages—is its Erlang/OTP integration. Since that part of the language doesn’t seem stable yet, a custom Gleam wrapper to the Erlang libraries may be a better option for now. And, while I wouldn’t use this in production yet, it feels ready enough, and pleasant enough to work with, that I would make it my default for personal projects that are a good fit for the BEAM. The type system, the LSP integration, and the error handling bring something distinct to the ecosystem, and I only expect it to get better on those fronts.
As far as a “Rust without memory management and with better concurrency” goes, I knew going in that Gleam could only be part of the answer. I don’t think Gleam can be a general-purpose language, just like Erlang cannot: the BEAM makes very specific and unusual trade-offs, which don’t make it a reasonable choice for applications that require computation efficiency, that need to be easy to distribute and operate, or that don’t benefit from high concurrency.
Perhaps the most interesting question, which I certainly won’t try to answer here, is: are type safety and let it crash compatible? Can they be complementary? Erlang is all about tolerating faults: accepting that you can’t possibly catch all errors, and you’d be better off designing your application to recover in the presence of the unexpected. This has the benefit that some error-handling code goes away, absorbed by the application structure and its supervision tree. Gleam, like Rust, makes you think preemptively about errors and spend more time dealing with them while writing code. One could argue that, by doing this, an entire problem space disappears—the silly type errors that inevitably slip into all dynamically typed programs—leaving OTP to deal with the truly unexpected. There’s a tension, but there’s also an interesting balance to strike here, and I’m curious to see where the Gleam community settles it.
notes
I recently took some time to try Gleam, the type-safe language that runs on the Erlang virtual machine. For a couple of weeks, I used it to build a little feed aggregator. These are my notes.