← History

Gleam: static types come to the BEAM

How Gleam adds a sound, fully-inferred type system to the Erlang VM - with no null, no exceptions, and one language that compiles to both Erlang and JavaScript.

GleamErlang

The Erlang virtual machine - the BEAM - has run some of the world's most reliable software for decades. Its languages were, almost by definition, dynamically typed: Erlang itself, Elixir, and the Lisp-flavoured LFE all defer most type questions to runtime, leaning on pattern matching, "let it crash," and supervision trees to stay robust. Even Luerl, which runs Lua 5.x on the BEAM, inherits Lua's dynamic typing.

Gleam is the outlier. Created by Louis Pilfold (first shown in 2016, with v0.1 in April 2019 and the stable v1.0.0 on 4 March 2024), it brings a sound, fully-inferred static type system to the BEAM while keeping everything that makes the platform great: lightweight processes, the actor model, OTP, and hot, fault-tolerant concurrency. This article looks at what "static types on the BEAM" actually buys you, and how Gleam's design - no null, no exceptions, errors-as-values - differs from its dynamically typed siblings.

A different kind of BEAM language

Compare the same idea - a function that may or may not find a value - across the family. In Erlang it is a convention; the compiler does not check it for you:

%% Erlang: returns {ok, V} | error by convention. Nothing stops a
%% caller from ignoring the shape, or a typo'd atom from slipping by.
find(Key, Map) ->
    case maps:find(Key, Map) of
        {ok, Value} -> {ok, Value};
        error       -> error
    end.

Elixir is the same story, dressed in friendlier syntax - still dynamic, still convention-driven:

# Elixir: {:ok, value} | :error is idiomatic, but the compiler
# will happily let you forget a clause or mistype an atom.
def find(key, map) do
  case Map.fetch(map, key) do
    {:ok, value} -> {:ok, value}
    :error -> :error
  end
end

In Gleam the very same shape is a real type the compiler understands and enforces. The return type Result(Int, Nil) is part of the signature, and every caller must deal with both outcomes:

import gleam/dict.{type Dict}

pub fn find(key: String, data: Dict(String, Int)) -> Result(Int, Nil) {
  dict.get(data, key)
}

There are no atoms-as-typos to slip through, no clause you can quietly forget. If you ignore the Error case at a call site, the program does not compile. That is the heart of the difference: in the other BEAM languages, correct shapes are a discipline; in Gleam, they are a guarantee.

Sound type inference: safety without the ceremony

Gleam's type system descends from the Hindley-Milner lineage shared by ML, OCaml, and Elm, with ergonomic touches borrowed from Rust. Two words matter here: sound and inferred.

Inferred means you rarely write types. The compiler works them out from how values flow:

import gleam/list

// No annotations needed - the compiler infers everything.
pub fn double_all(numbers) {
  list.map(numbers, fn(n) { n * 2 })
}
// Inferred type: fn(List(Int)) -> List(Int)

Sound means the inference does not lie. If the compiler says a value is an Int, it is an Int at runtime - there are no escape hatches like any, no unchecked casts that quietly defeat the checker. (This is exactly where Erlang's optional Dialyzer differs: Dialyzer is a success-typing tool that reports only what it can prove wrong, so it is deliberately unsound and will stay quiet about many real mistakes. Gleam flips that default - if it cannot prove your program type-correct, it refuses to build.)

You can still annotate for documentation and to pin down intent, and Gleam supports generics through lowercase type variables:

pub type Tree(value) {
  Leaf
  Node(left: Tree(value), value: value, right: Tree(value))
}

pub fn size(tree: Tree(value)) -> Int {
  case tree {
    Leaf -> 0
    Node(left, _, right) -> 1 + size(left) + size(right)
  }
}

Because matching is checked for exhaustiveness, adding a new variant to a custom type turns every case that forgot to handle it into a compile error - pointing you at exactly the code that needs updating. Refactoring stops being an act of faith.

No null: the billion-dollar mistake, declined

Gleam has no null, no nil-as-a-value, no undefined. (Nil exists, but it is an ordinary value of its own type, not a stand-in that can appear anywhere.) "Absence" is modelled explicitly with the Option type, so the possibility of nothing is visible in the type and cannot be forgotten:

import gleam/option.{type Option, None, Some}

pub fn first_even(numbers: List(Int)) -> Option(Int) {
  case numbers {
    [] -> None
    [n, ..] if n % 2 == 0 -> Some(n)
    [_, ..rest] -> first_even(rest)
  }
}

To use an Option(Int) you must acknowledge that it might be None - there is no way to "just dereference it" and hope. Contrast this with the dynamic BEAM languages, where nil/undefined and "an atom you didn't expect" flow freely until something pattern-matches the wrong shape at runtime. In Gleam, the absent case is a branch you are forced to write.

No exceptions: errors are ordinary values

The BEAM's celebrated answer to errors is "let it crash" - and Gleam fully embraces it for unexpected, unrecoverable faults via supervision. But for expected failures - parsing, lookups, validation, I/O - Gleam does not throw. There is no try/catch in normal control flow. Recoverable failure is a value: the built-in Result(a, e), which is Ok(a) or Error(e).

import gleam/int

pub fn parse_age(text: String) -> Result(Int, String) {
  case int.parse(text) {
    Ok(n) if n >= 0 -> Ok(n)
    Ok(_) -> Error("age cannot be negative")
    Error(_) -> Error("not a number: " <> text)
  }
}

Because the failure mode is encoded in the return type, a caller cannot pretend it will not happen. Compare a failed integer parse across the family. In Erlang, erlang:list_to_integer/1 raises:

%% Erlang: this throws a `badarg` error you must remember to catch.
list_to_integer("oops").
%% ** exception error: bad argument

In Gleam, the analogous operation simply returns an Error value that the type system makes you handle:

case int.parse("oops") {
  Ok(n)  -> "got " <> int.to_string(n)
  Error(_) -> "not a number"
}

Chaining fallible steps without nesting

A full case per step gets noisy, so gleam/result offers combinators, and the use expression flattens callbacks into straight-line code that short-circuits on the first Error - Gleam's answer to exceptions and to Rust's ?:

import gleam/result

pub fn total_age(a: String, b: String) -> Result(Int, String) {
  use first <- result.try(parse_age(a))   // bail out early if a is invalid
  use second <- result.try(parse_age(b))  // bail out early if b is invalid
  Ok(first + second)
}

If either parse fails, total_age returns that error immediately; otherwise it falls through to Ok(first + second). There is no hidden, non-local control flow - every place a value can be an error is right there in the types and the code. For genuine bugs and unfinished work, Gleam still offers panic and todo, which crash the process; they are for impossible states and scaffolding, never for ordinary failures.

One language, two targets: Erlang and JavaScript

Gleam's compiler (written in Rust) does not target the BEAM exclusively. Since 2021 it compiles to two backends:

The same Gleam source can power your backend on the BEAM and your frontend in the browser. When you need the host platform, the @external attribute binds a typed Gleam signature to a per-target implementation:

// One function, two implementations - chosen by compile target.
@external(erlang, "erlang", "system_time")
@external(javascript, "./ffi.mjs", "now")
pub fn now() -> Int

You provide the signature and the compiler trusts it - the controlled escape hatch into either ecosystem. Packages come from Hex, the same registry Erlang and Elixir share, so Gleam slots into existing BEAM projects rather than replacing them. A common pattern is to wrap a mature Erlang library behind a thin, typed Gleam interface, giving the rest of your code static guarantees over battle-tested internals.

Type-safe actors on the BEAM

Concurrency is where the BEAM earns its reputation, and Gleam keeps the actor model - isolated, lightweight processes that communicate only by message passing - but makes the messages typed. In Erlang, a process mailbox accepts anything; an unexpected message is a runtime surprise:

%% Erlang: the mailbox is untyped. Any term can be sent here, and a
%% message you didn't plan for just sits unmatched in the mailbox.
loop(Count) ->
    receive
        increment       -> loop(Count + 1);
        {get, From}     -> From ! {count, Count}, loop(Count);
        _Anything       -> loop(Count)   %% silently ignored
    end.

Gleam's gleam/otp/actor wraps this so an actor declares the message type it understands. A Subject is a typed handle to a process: you can only send it messages of the type it expects, and the compiler rejects anything else. The state and the message are part of the actor's signature:

import gleam/erlang/process.{type Subject}
import gleam/otp/actor

pub type Message {
  Increment
  Decrement
  GetCount(reply_to: Subject(Int))
}

fn handle(count: Int, message: Message) -> actor.Next(Int, Message) {
  case message {
    Increment -> actor.continue(count + 1)
    Decrement -> actor.continue(count - 1)
    GetCount(reply_to) -> {
      process.send(reply_to, count)   // typed reply: must be an Int
      actor.continue(count)
    }
  }
}

pub fn main() {
  let assert Ok(started) =
    actor.new(0) |> actor.on_message(handle) |> actor.start
  let counter = started.data

  actor.send(counter, Increment)
  actor.send(counter, Increment)
  actor.send(counter, Decrement)

  // ask for the count and wait for a typed reply
  let count = actor.call(counter, waiting: 1000, sending: GetCount)
  count   // 1
}

The case message is checked for exhaustiveness, so you cannot forget a variant; and because every send goes through a typed Subject, the "unexpected message" class of bug largely disappears at compile time. The actor still owns its state in its own process - safe concurrent mutation without locks, since nothing is shared. (The OTP library's exact API has evolved across releases, so check your gleam_otp version; the typed-Subject, typed-message guarantee is the constant.)

"Let it crash" and supervision are intact too. You can build typed supervision trees with gleam/otp/static_supervisor, so a crashing worker is restarted into a known-good state - the BEAM's fault-tolerance story, now with the message and state types checked before your program ever runs.

Where Gleam sits in the family

Gleam does not try to be a superset of Erlang or a replacement for Elixir. It is a deliberately small language - designed to be learnable in an afternoon - that makes one strong bet the others do not: that a sound static type system, paired with no-null and errors-as-values, pays for itself in production reliability without giving up the BEAM.

erlang elixir gleam lfe luerl
Typing dynamic dynamic static, sound, inferred dynamic dynamic
Null / nil atoms, undefined nil none - Option atoms nil
Errors exceptions + {ok,_} exceptions + {:ok,_} Result values exceptions errors/pcall
Backends BEAM BEAM BEAM + JavaScript BEAM BEAM (host)
Actor messages untyped mailbox untyped mailbox typed Subject untyped mailbox n/a

For teams that love the BEAM's concurrency and resilience but want the compiler to catch shape errors, null mistakes, and forgotten failure cases before deployment - and who'd like to ship the same language to the browser - Gleam is a genuinely new option in a family that, until recently, only knew dynamic typing.

Further reading