Gleam — Reference

Source: https://gleam.run/

Gleam

  • Created: 2016 by Louis Pilfold; first release 2018; 1.0 released 2024-03-04
  • Latest stable: Gleam v1.16.0 (2026-04-24)
  • Status: Stable post-1.0 with strict semver and a no-breaking-changes-in-1.x commitment. Active monthly minor releases
  • Paradigms: Pure functional, ML-family, immutable-by-default
  • Typing: Static, strong, sound, Hindley-Milner with row-polymorphic records. No subtyping, no inheritance, no nulls, no exceptions in pure code
  • Memory: Inherits from BEAM (preemptive concurrent garbage collector, per-process heaps, never stops the world). For JS target: V8/SpiderMonkey GC
  • Compilation: Compiles to Erlang (then BEAM bytecode via erlc) OR JavaScript (with TypeScript .d.ts declarations)
  • Primary domains: Backend services on BEAM, full-stack web (Lustre on JS), Erlang/Elixir interop layers, fault-tolerant distributed systems
  • Official docs: https://gleam.run/ · Tour: https://tour.gleam.run/ · Cheatsheet: https://gleam.run/cheatsheets/

At a glance

Gleam puts a sound static type system on top of the BEAM (Erlang’s VM), giving you Erlang’s preemptive scheduler, hot code reload, distributed Erlang networking, and OTP supervision trees — but with a Rust-quality type checker that catches errors Erlang never could. Also compiles to JavaScript (with TS types), so you can ship one library to both Node/Deno/Bun and the browser. The language is intentionally tiny — no macros, no metaprogramming, no operator overloading. The trade-off is a small standard library you supplement with the Hex package ecosystem (which Gleam shares with Elixir/Erlang).

Getting started

Install: Single binary, no separate version manager, but asdf and mise plugins exist:

# macOS
brew install gleam erlang rebar3
# Linux: download from https://github.com/gleam-lang/gleam/releases
# Windows: scoop install gleam   (also chocolatey, winget)
# Or via asdf:
asdf plugin add gleam && asdf install gleam latest && asdf global gleam latest
 
gleam --version       # → gleam 1.16.0

You also need Erlang/OTP for the BEAM target and Node (or Deno/Bun) for the JS target.

Hello world:

gleam new hello
cd hello
gleam run            # → "Hello from hello!"

The generated src/hello.gleam:

import gleam/io
 
pub fn main() -> Nil {
  io.println("Hello from hello!")
}

Project layout:

hello/
├── gleam.toml         # manifest (deps, target)
├── manifest.toml      # lockfile
├── src/hello.gleam    # entry: pub fn main()
└── test/hello_test.gleam

Build/package tool: gleam is the entire toolchain.

  • gleam new NAME — scaffold project
  • gleam run — build + run main
  • gleam test — run tests via gleeunit (default test runner)
  • gleam build — compile only
  • gleam check — type-check only (fast)
  • gleam format — canonical formatter
  • gleam docs build — generate HTML docs
  • gleam add PKG — add a Hex package; gleam deps update
  • gleam shell — Erlang/IEx-style shell against your project
  • gleam export erlang-shipment — produce a deployable BEAM bundle
  • gleam publish — push to Hex

Target selection in gleam.toml:

target = "erlang"   # or "javascript"

Or per-command: gleam run --target javascript.

REPL: Indirectly — gleam shell drops you in Erlang’s shell with your project’s modules loaded. There’s no Gleam-syntax REPL.

Basics

Types & literals:

  • Int (arbitrary-precision on BEAM, doubles on JS within safe-integer range), Float, Bool (True/False), String (UTF-8), Nil (the unit type), BitArray (bit strings)
  • List(a) (linked list), Result(a, e), Option(a) is NOT built-in — Gleam uses Result everywhere
  • Tuples #(a, b, c) are heterogeneous fixed-size
  • Records as custom types: pub type User { User(name: String, age: Int) } — constructors are functions; field access via .name

Variables & scoping: Single-assignment, immutable, function-scoped. let declares; rebinding shadows:

let x = 1
let x = x + 1   // OK — shadowing creates a new binding

let assert is the explicit “I know this matches” pattern that crashes if it doesn’t:

let assert Ok(value) = parse(input)

Control flow: Everything is an expression.

  • case for pattern matching (exhaustive, compile-time-checked):

    case x {
      0 -> "zero"
      n if n > 0 -> "positive"
      _ -> "negative"
    }
  • No if/else keywords — use case on a Bool

  • No loops — recursion or stdlib higher-order functions

Functions:

pub fn add(a: Int, b: Int) -> Int {
  a + b
}

First-class. Labeled arguments (caller-side names): pub fn replace(in str: String, each pattern: String, with new: String) called as replace(in: "hi", each: "h", with: "b").

Strings: UTF-8, immutable. Concatenation: <> (e.g. "hello, " <> name). No printf-style interpolation — build with string.concat(["a", b]) or string.inspect(value). Multiline strings via consecutive \ns.

Collections: Linked List(a) is the canonical sequence. dict.Dict(k, v) from gleam/dict. set.Set(a) from gleam/set. No mutable arrays in pure Gleam — use BitArray for byte buffers, or call into Erlang/JS for a mutable structure.

Intermediate

Type system depth:

  • Custom types (algebraic data types):

    pub type Shape {
      Circle(radius: Float)
      Rectangle(width: Float, height: Float)
    }

    Constructors create values; pattern-match in case. Exhaustiveness enforced.

  • Generics: pub fn first(list: List(a)) -> Result(a, Nil) — type variables are lowercase, no annotations needed for definitions

  • No subtyping, no overloading, no implicit conversions — language stays simple

  • Records are sugar for single-constructor custom types; field updates: User(..user, name: "Bob") (record-update syntax)

Modules: Files are modules; src/foo/bar.gleam is module foo/bar. Public via pub. Imports:

import gleam/io
import gleam/list.{map, filter}     // unqualified imports
import gleam/string as str          // alias

Error handling: No exceptions in pure Gleam. Convention: return Result(ok_value, error_value):

pub fn divide(a: Int, b: Int) -> Result(Int, String) {
  case b {
    0 -> Error("divide by zero")
    _ -> Ok(a / b)
  }
}

The use syntax desugars callback-passing into linear-looking code. It’s Gleam’s monadic-do equivalent:

use file <- result.try(open("foo.txt"))
use contents <- result.try(read(file))
Ok(contents)

This desugars to nested callbacks. The pattern works for any function whose last arg is a callback (result.try, list.try_each, task.await, etc.).

Concurrency: You get all of OTP, with types. The gleam_otp package wraps actors, supervisors, and gen_servers in typed APIs:

  • gleam/otp/actor — typed actor framework (replaces Erlang’s gen_server). Messages are typed; the compiler enforces every actor handles its message type
  • gleam/otp/supervisor — supervision trees (one_for_one, rest_for_one, etc.)
  • gleam/otp/task — fire-and-forget or await typed tasks
  • Process IDs (Subject(message_type)) are typed by the message they accept — sending the wrong message to an actor is a compile error

This is the headline feature: BEAM’s actor model with end-to-end type safety.

I/O: gleam/io (println, debug), gleam/erlang/file (BEAM target only), simplifile (cross-target file I/O), gleam/http + mist/wisp for HTTP servers. JS target uses gleam/javascript/promise for Promise.

Stdlib highlights: gleam_stdlib is the official standard library and is intentionally minimal: list, dict, set, string, int, float, bool, result, option (yes, Option is here, just not built-in), iterator (lazy sequences), dynamic (untyped data + decoders), regex, uri, pair, function. Decoders for Dynamic are how you parse untyped Erlang data (e.g. JSON parses to Dynamic, you write a decoder to typed Gleam).

Advanced

Memory model — BEAM target: Each Erlang process has its own heap (typically a few KB), with a per-process generational GC. Per-process GC means no global stop-the-world — only a single process pauses, briefly. Inter-process communication is by message copy (or refcounted binaries for >64 bytes). On the JS target, you inherit V8’s GC.

Concurrency deep dive (BEAM): Erlang processes are user-space (not OS) processes with preemptive scheduling. Spawn millions cheaply. Distributed Erlang lets processes communicate across nodes transparently. Gleam wraps all of this — gleam_otp gives typed wrappers; raw Erlang process APIs are accessible via gleam/erlang/process. The headline feature: typed Subject(message_type) — sending the wrong message type doesn’t compile. Hot code upgrades work as in Erlang (with care).

FFI — external declarations: Call Erlang/Elixir/JavaScript from Gleam:

@external(erlang, "lists", "reverse")
@external(javascript, "./my_ffi.mjs", "reverse")
pub fn reverse(list: List(a)) -> List(a)

The two @external annotations let one Gleam declaration target both runtimes. JS FFI files live in src/ next to the .gleam file. From Erlang/Elixir you can call Gleam modules directly — they look like ordinary Erlang modules with snake_case-named functions.

Reflection: Limited by design. gleam/dynamic lets you inspect untyped data with decoder combinators; string.inspect(value) produces a debug string for any value. There’s no runtime type info beyond what Erlang/JS expose for their own values.

Performance tools: On BEAM: :observer.start() (Erlang’s GUI tool), :recon for production introspection, :fprof/:eprof/:cprof profilers, :dbg tracer. Gleam compiles to small, idiomatic Erlang, so all BEAM tooling works as-is. On JS: standard Chrome DevTools / Node --inspect. gleam build --warnings-as-errors for CI.

God mode

Type system on top of BEAM — what you actually get:

  • Typed actors: actor.start(initial_state, handle_message) returns a Subject(msg_type). Sending the wrong msg type = compile error. Erlang has nothing like this; Elixir has dialyzer (which is structural and best-effort)
  • Typed supervisors: supervisor.start_spec(spec) where children types are checked
  • Distributed-Erlang-aware: subjects can be sent across nodes; the type system tracks what messages each accepts

Two targets, one source:

  • target = "javascript" in gleam.toml → output .mjs files in build/dev/javascript/<pkg>/
  • TypeScript declarations are generated alongside (build/.../*.d.mts)
  • Same Gleam source can compile to both targets if it avoids target-specific FFI
  • Conditional compilation via @target(erlang)/@target(javascript) annotations on declarations

use syntax (sugar for monadic chaining): Read it as “use the next value, then continue”:

use a <- result.try(parse("42"))
use b <- result.try(parse("7"))
Ok(a + b)

desugars to:

result.try(parse("42"), fn(a) {
  result.try(parse("7"), fn(b) {
    Ok(a + b)
  })
})

Works for any function whose final argument is a callback — no monad/applicative typeclasses needed.

No macros — and the rationale: Louis Pilfold has explicitly chosen no macros, no metaprogramming. Reasons: (1) compile-time safety — you can read any Gleam code top-to-bottom and know what it does, (2) tooling simplicity — LSP, formatter, and refactoring tools are dramatically easier, (3) ecosystem coherence — no custom DSLs fragmenting the community. Trade-off: things Elixir’s Ecto.Query or Rust’s serde_derive do via macros require explicit code in Gleam (decoders for JSON, query builders as values).

use + gleam_otp = typed-do-style actor code. The combination produces remarkably readable concurrent code:

use msg <- actor.receive(state, 1000)
case msg { ... }

Hex package ecosystem (shared with Erlang/Elixir): ~16,000 packages on https://hex.pm/. Most Erlang/Elixir libs work in Gleam via thin wrapper packages (e.g. gleam_erlang, gleam_crypto). Searching for a Gleam-native package: https://packages.gleam.run/.

Custom build phases: Mostly handled by rebar3/mix under the hood when integrating with Erlang/Elixir projects. Pure-Gleam projects use only gleam. For Elixir interop in a mix project, use the mix_gleam Mix plugin.

Idioms & style

  • Naming: snake_case for values/functions/modules, PascalCase for types and constructors, SCREAMING_SNAKE_CASE is not idiomatic — use pub const my_constant = 42
  • Formatter: gleam format is canonical, non-configurable — run on save. Required by gleam publish (won’t publish unformatted code)
  • Linter: Compiler warnings are aggressive — --warnings-as-errors for CI. No separate linter; the type system + warnings cover most lint cases
  • Idiomatic patterns: prefer Result over Option for fallible operations (carry an error reason), use use for nested Result.try/Task.await, build small focused modules, lean on the type system to make illegal states unrepresentable, write decoders for Dynamic data instead of trusting external schemas
  • OTP idioms: prefer gleam_otp typed wrappers over raw gleam/erlang/process; use a Subject(MyMessages) per actor; use supervisors over manual restart logic
  • Expert review focus: correct error type carried in Results (don’t make everything Result(a, String)), no let assert outside tests, exhaustive case (compiler enforces but watch for warnings), proper supervision strategy, target-specific FFI behind a stable Gleam-typed shim

Ecosystem

DomainLibrary
Web framework (server)Wisp (over Mist), Mist (HTTP server), glen
Web framework (full-stack)Lustre (Elm-architecture frontend, compiles to JS — also runs server-side)
HTTP clientgleam_http, gleam_httpc, lustre_http
Databasegleam_pgo (Postgres), gleam_redis, squirrel (typed SQL via Postgres preparation)
JSONgleam_json (parse to/from Dynamic)
Templatinglustre/element/html (typed HTML), glentities
Testinggleeunit (default; ExUnit-style), birdie (snapshot testing)
OTP / actorsgleam_otp (official typed wrappers)
Distributed systemsUse raw :gleam/erlang/process + Distributed Erlang facilities
Docsgleam docs build → published to https://hexdocs.pm/

Notable users: Strand (Rust + Gleam observability), Octavius (Lustre full-stack), various BEAM consultancies. Gleam community on Discord is active and well-moderated.

Gotchas

  • No Option/Some/None built inOption is in gleam/option. Most APIs return Result(a, e) instead
  • No if — only case. Some find this annoying initially; it’s a deliberate small-language choice
  • Ints differ between targets: BEAM ints are arbitrary-precision; JS ints are doubles in safe-integer range (±2^53). Code that overflows one target may not overflow the other
  • use desugars to nested closures — stack traces and reading mid-pipeline values can be confusing until you internalize the desugaring
  • No macros means more boilerplate for serialization, query building, etc. — write the code or use a code-gen tool like squirrel
  • Gleam ↔ Erlang interop is one-way-typed: Erlang code calling Gleam is unchecked (it’s just an Erlang module to Erlang); Gleam calling Erlang requires external declarations with manual type signatures (the compiler trusts you)
  • Hot code upgrades work but require designing actors with code_change/3 callbacks (typed wrappers for this are still maturing)
  • JS target’s stdlib has gaps vs the BEAM target — some gleam_erlang/* modules have no JS equivalent
  • let assert will crash at runtime if the pattern doesn’t match — use only for invariants you’ve proven, prefer case + Error for fallible parsing
  • Exhaustiveness is at compile time — but adding a constructor to a pub type in a library is a breaking change for downstream case expressions. Plan API evolution carefully
  • Performance: Gleam compiles to idiomatic Erlang (or readable JS); it’s as fast as Erlang for BEAM workloads. NOT a numerical computing language — for that, drop down to Erlang’s NIFs (Rustler, Zigler) and call from Gleam
  • gleam shell puts you in Erlang’s shell, not a Gleam REPL — there is no Gleam-syntax REPL

Citations