Learn Gleam
A statically typed, friendly language for the BEAM (and JavaScript) with sound type inference, no nulls, and no exceptions.
Setup and Your First Program
Install Gleam, create a project, and run code on both the Erlang and JavaScript targets.
Setup and Your First Program
Gleam is a young but stable language: it reached version 1.0 in March 2024, and since then the language has a backwards-compatibility promise, so code you write today will keep compiling. Gleam is a statically typed functional language for the Erlang virtual machine (the BEAM), and it can equally compile to JavaScript. One small tool, the gleam binary, does everything: scaffolding, building, running, testing, and formatting.
Installing Gleam
Gleam is distributed as a single self-contained binary. You can install it with most package managers, or download a release from the website.
# macOS / Linux via Homebrew
$ brew install gleam
# or with the Rust toolchain
$ cargo install gleam
Gleam compiles to Erlang or JavaScript, so to actually run your program you also need a runtime: Erlang/OTP for the default target, or Node.js / Deno / Bun for the JavaScript target. Check everything is in place:
$ gleam --version
gleam 1.x.x
$ erl -version # Erlang/OTP runtime
Creating a project
gleam new scaffolds a complete project, including a Hex-compatible build configuration and a test runner.
$ gleam new hello
$ cd hello
The layout is small and predictable:
hello/
gleam.toml # project manifest: name, version, dependencies
manifest.toml # locked dependency versions (committed)
src/
hello.gleam # your code; the module matching the project name is the entrypoint
test/
hello_test.gleam
The gleam.toml manifest declares the package and its dependencies (resolved from Hex, the BEAM ecosystem's package registry):
name = "hello"
version = "1.0.0"
[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
Your first program
A Gleam program starts at the main function of the module named after your project. Open src/hello.gleam:
import gleam/io
pub fn main() {
io.println("Hello, Gleam!")
}
pub makes the function visible to other modules; import gleam/io pulls in the standard library's I/O module. Run it:
$ gleam run
Compiling hello
Compiled in 0.42s
Running hello.main
Hello, Gleam!
Targeting Erlang or JavaScript
The same source compiles to two backends. The default target is Erlang; switch with --target:
$ gleam run # Erlang (the BEAM)
$ gleam run --target javascript # Node.js / Deno / Bun
This dual-target story is one of Gleam's headline features: you can write a library once and use it from an Erlang/Elixir backend and from a browser front end.
The commands you will use daily
| Command | What it does |
|---|---|
gleam run |
Build and run the project |
gleam build |
Compile without running |
gleam test |
Run the test suite (gleeunit by default) |
gleam format |
Reformat to the one canonical style |
gleam check |
Type-check fast without producing output |
gleam add <pkg> |
Add a Hex dependency to gleam.toml |
gleam shell |
Open an Erlang shell with your code loaded |
Gleam has a single built-in formatter, so there are no style debates: run gleam format (editors do it on save) and move on.
Reference
- Getting started: https://gleam.run/getting-started/
- The language tour: https://tour.gleam.run/
With a project building on both targets, you are ready for the basics: values, types, and functions.
Values, Types, and Functions
Immutable bindings, the core types, type inference, and how functions and the pipe operator work.
Values, Types, and Functions
Gleam is statically typed with sound, full type inference. In practice that means you almost never write type annotations, yet every program is fully type-checked before it runs - there are no runtime type errors. Everything is immutable, and there is no null and no undefined.
Bindings with let
You introduce a value with let. Bindings are immutable: the name always refers to the same value.
let name = "Gleam"
let answer = 42
let pi = 3.14159
You can reuse a name; this shadows the old binding with a new one rather than mutating anything:
let x = 1
let x = x + 1 // a new binding; the old x is unchanged
Annotations are optional, written after a colon. They are useful as documentation or to pin a type:
let count: Int = 0
let ratio: Float = 0.5
The core types
Gleam's primitives are deliberately few:
Int- arbitrary-precision integers on the Erlang target (1,-7,1_000_000).Float- 64-bit floats (3.0,-0.5). Note the decimal point:1is anInt,1.0is aFloat.String- UTF-8 text in double quotes ("hi").Bool-TrueorFalse(capitalised).List(a)- an immutable, singly linked list of one element type.- Tuples - fixed-size, mixed-type groups written with
#(...).
A crucial design choice: Int and Float are separate types with separate operators, and there is no implicit coercion. Integer arithmetic uses + - * /; float arithmetic uses the dotted forms +. -. *. /.:
let a = 1 + 2 // Int -> 3
let b = 1.0 +. 2.0 // Float -> 3.0
// let c = 1 + 2.0 // compile error: Int + Float is not allowed
This removes a whole class of subtle numeric bugs. Convert explicitly when you need to, e.g. with int.to_float from the standard library.
Strings
Strings are UTF-8 and joined with the <> operator:
let greeting = "Hello, " <> "world!"
Lists and tuples
A List holds many values of one type; a tuple holds a fixed number of values of possibly different types:
let nums = [1, 2, 3] // List(Int)
let prepended = [0, ..nums] // [0, 1, 2, 3] (the spread adds to the front)
let pair = #("age", 36) // #(String, Int)
let n = pair.1 // tuple access by position -> 36
Lists are immutable and persistent: [0, ..nums] creates a new list sharing the tail with the old one; nums is untouched.
Functions
Functions are declared with fn and made public with pub. The last expression is the return value - there is no return keyword:
pub fn double(x: Int) -> Int {
x * 2
}
fn greet(name: String) -> String {
"Hello, " <> name
}
Parameter and return type annotations are optional; inference fills them in. Anonymous functions use the same fn keyword and are first-class values you can pass around:
let add = fn(a, b) { a + b }
let result = add(2, 3) // 5
Labelled arguments
Call sites can name arguments for clarity, in any order. You declare a label before the parameter name:
pub fn replace(in string: String, each pattern: String, with replacement: String) -> String {
// ...
string
}
// at the call site, labels make intent obvious:
replace(in: "hello", each: "l", with: "L")
The pipe operator
The pipe |> passes the value on its left as the first argument of the function on its right. It turns nested calls into a top-to-bottom pipeline that reads like a recipe:
import gleam/string
import gleam/list
// without pipes (read inside-out):
let shouted = string.uppercase(string.trim(" hi "))
// with pipes (read top-to-bottom):
let shouted =
" hi "
|> string.trim
|> string.uppercase
// chains naturally over collections:
let total =
[1, 2, 3, 4]
|> list.filter(fn(x) { x % 2 == 0 })
|> list.map(fn(x) { x * 10 })
|> list.fold(0, fn(acc, x) { acc + x }) // 60
The pipe is everywhere in idiomatic Gleam; the standard library is designed so the "subject" of each function comes first, exactly so pipes flow.
Reference
- Language tour - basics: https://tour.gleam.run/basics/
- gleam/string and gleam/list in the stdlib docs: https://hexdocs.pm/gleam_stdlib/
Next we build our own data types and take them apart with pattern matching.
Custom Types and Pattern Matching
Model data with custom types (sum and product types), then destructure it with exhaustive case expressions.
Custom Types and Pattern Matching
Gleam has no classes and no inheritance. You model data with custom types - which can be sum types (a choice between variants), product types (records of fields), or both at once - and you take them apart with pattern matching. This pairing is the heart of the language.
Defining a custom type
A custom type lists one or more constructors. Each constructor can carry data:
pub type Shape {
Circle(radius: Float)
Rectangle(width: Float, height: Float)
Point
}
Shape is the type; Circle, Rectangle, and Point are its constructors (and also the functions you call to build values). Construct values by calling a constructor:
let a = Circle(radius: 2.0)
let b = Rectangle(width: 3.0, height: 4.0)
let c = Point
A type with a single constructor that has named fields acts like a record:
pub type User {
User(name: String, age: Int, admin: Bool)
}
let u = User(name: "Ada", age: 36, admin: True)
let who = u.name // field access -> "Ada"
Updating records
Because everything is immutable, you do not mutate a record - you build a new one. The record update syntax copies all fields except the ones you override:
let older = User(..u, age: u.age + 1) // a new User, same name/admin, age 37
case: exhaustive pattern matching
case is Gleam's pattern-matching expression. It tries patterns top to bottom and is an expression, so it returns a value. Crucially, the compiler checks that your patterns are exhaustive - if you forget a variant, your code will not compile:
import gleam/float
pub fn area(shape: Shape) -> Float {
case shape {
Circle(radius: r) -> 3.14159 *. r *. r
Rectangle(width: w, height: h) -> w *. h
Point -> 0.0
}
}
If you later add a Triangle variant to Shape, every non-exhaustive case in your program becomes a compile error pointing you at exactly what to handle. This is how Gleam turns "did I cover every case?" from a runtime worry into a compile-time guarantee.
Patterns can do a lot
Patterns match literals, destructure tuples and lists, bind names, and nest:
pub fn describe(n: Int) -> String {
case n {
0 -> "zero"
1 -> "one"
_ -> "many" // _ is the catch-all
}
}
pub fn head_or_zero(items: List(Int)) -> Int {
case items {
[] -> 0
[first, ..] -> first // bind the head, ignore the tail with ..
}
}
pub fn classify(point: #(Int, Int)) -> String {
case point {
#(0, 0) -> "origin"
#(x, 0) -> "on x-axis at " <> int_to_string(x)
#(0, _) -> "on y-axis"
#(_, _) -> "elsewhere"
}
}
Alternative patterns and guards
Combine patterns with |, and add a boolean guard with if:
pub fn sign(n: Int) -> String {
case n {
0 -> "zero"
n if n > 0 -> "positive"
_ -> "negative"
}
}
pub fn is_vowel(c: String) -> Bool {
case c {
"a" | "e" | "i" | "o" | "u" -> True
_ -> False
}
}
You can also match several values at once by separating the subjects with commas:
pub fn combine(a: Bool, b: Bool) -> String {
case a, b {
True, True -> "both"
True, False | False, True -> "one"
False, False -> "neither"
}
}
String prefix patterns
A handy Gleam feature: you can match the start of a string and bind the rest.
pub fn route(path: String) -> String {
case path {
"/users/" <> id -> "user " <> id
"/" -> "home"
_ -> "not found"
}
}
let with patterns, and let assert
A plain let can itself destructure, as long as the pattern is the only possibility (so the match cannot fail):
let #(x, y) = #(1, 2) // x = 1, y = 2
When you know a match must succeed but the compiler cannot prove it, use let assert. It performs the match and panics at runtime if it fails - useful in tests and prototypes, but avoid it in production paths:
let assert [first, ..] = [10, 20, 30] // first = 10; would panic on []
Generics
Custom types can be parameterised over other types. The lowercase names are type variables:
pub type Tree(value) {
Leaf
Node(left: Tree(value), value: value, right: Tree(value))
}
Tree(Int) and Tree(String) are both valid; the same definition works for any element type. The standard library's List(a), Option(a), and Result(a, e) are defined exactly this way.
Reference
- Custom types (the tour): https://tour.gleam.run/data-types/custom-types/
- Pattern matching (the tour): https://tour.gleam.run/flow-control/case-expressions/
Next: how Gleam handles missing values and errors without null or exceptions.
No Nulls, No Exceptions: Option, Result, and use
Handle absence with Option, recoverable failure with Result, and chain fallible steps with the use expression.
No Nulls, No Exceptions: Option, Result, and use
Two of Gleam's defining promises are no nulls and no exceptions. There is no null, nil, or undefined to forget about, and ordinary functions do not throw. Instead, absence and failure are represented by ordinary values - Option and Result - that the type system forces you to handle.
Option for absence
Option(a) from gleam/option is either Some(value) or None. Use it when a value might legitimately be missing - the type makes the "missing" case impossible to ignore:
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 the result you must handle both cases, typically with case or with helpers like option.unwrap:
import gleam/option
let n = first_even([1, 3, 4, 5]) |> option.unwrap(or: 0) // 4
Result for recoverable failure
Result(a, e) is either Ok(a) on success or Error(e) on failure. It is built into the language (no import needed for Ok/Error), and the standard library uses it for everything that can fail - parsing, lookups, I/O:
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 is in the return type, a caller cannot pretend it will not happen - they must pattern match or use a result helper:
case parse_age("36") {
Ok(age) -> "age is " <> int.to_string(age)
Error(message) -> "invalid: " <> message
}
Combinators on the gleam/result module
You rarely need a full case for every step. gleam/result provides combinators to transform and chain:
import gleam/result
// map the Ok value, leave Error untouched
let doubled = Ok(21) |> result.map(fn(x) { x * 2 }) // Ok(42)
// supply a default
let n = Error("nope") |> result.unwrap(or: 0) // 0
// chain a fallible step (a.k.a. and_then / flat_map)
pub fn parse_and_halve(text: String) -> Result(Int, String) {
parse_age(text)
|> result.try(fn(age) {
case age % 2 == 0 {
True -> Ok(age / 2)
False -> Error("age is odd")
}
})
}
result.try is the workhorse: it runs the next step only if the previous one was Ok, short-circuiting to the first Error.
The use expression: flat error handling
Chaining several fallible steps with nested callbacks gets noisy. Gleam's use expression is general-purpose callback sugar that flattens the nesting. use x <- f(...) means "call f, and bind the value it passes to its callback as x, with the rest of the block as that callback."
Combined with result.try, it gives you clean, top-to-bottom error handling that reads like normal sequential code but short-circuits on the first Error:
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_age returns Error, the whole function returns that error immediately; otherwise execution falls through to Ok(first + second). This is Gleam's answer to exceptions and to ?-style propagation in other languages - explicit, type-checked, and with no hidden control flow.
The same use works for any "take a callback" pattern, not just Result:
import gleam/list
pub fn pairs(xs: List(Int), ys: List(Int)) -> List(#(Int, Int)) {
use x <- list.flat_map(xs)
use y <- list.map(ys)
#(x, y)
}
When something truly should not happen
For genuinely impossible states or not-yet-written code, Gleam offers panic and todo, which crash the process with a message. They are for bugs and scaffolding, never for ordinary failures - those should be a Result:
pub fn must_be_positive(n: Int) -> Int {
case n > 0 {
True -> n
False -> panic as "expected a positive number"
}
}
pub fn not_done_yet() -> Int {
todo as "implement this later"
}
let assert (from the previous lesson) is the third member of this family: it panics if a pattern does not match.
Reference
- The Result type (the tour): https://tour.gleam.run/data-types/results/
- The use expression (the tour): https://tour.gleam.run/advanced-features/use/
- gleam/result and gleam/option: https://hexdocs.pm/gleam_stdlib/
Next we organise code into modules and pull in dependencies.
Modules, Opaque Types, Dependencies, and Testing
Organise code into modules, hide implementation with opaque types, add Hex packages, and write tests with gleeunit.
Modules, Opaque Types, Dependencies, and Testing
Real programs are more than one file. This lesson covers how Gleam organises code into modules, how to protect invariants with opaque types, how to pull in libraries from Hex, and how to test - all with the same single gleam tool.
Modules
Every .gleam file is a module, named by its path under src/. A file at src/app/math.gleam is the module app/math. You import a module and refer to its public members through the module's last name:
// src/app/math.gleam
pub fn square(x: Int) -> Int {
x * x
}
fn helper(x: Int) -> Int { // no `pub` -> private to this module
x + 1
}
// src/app.gleam
import app/math
pub fn main() {
math.square(5) // 25
}
Only pub definitions are visible outside their module; everything else is private. You can also import specific names directly, and alias modules:
import gleam/list.{map, filter} // unqualified: use map(...) and filter(...)
import gleam/string as str // aliased: use str.uppercase(...)
Types and their constructors are imported separately. import gleam/option.{type Option, Some, None} brings in the type Option (note the type keyword) and the constructors Some/None.
Opaque types: enforce invariants
A normal custom type exposes its constructors to importers. An opaque type keeps its constructors private to the defining module, so other code can only build and inspect values through the functions you provide. This lets you guarantee invariants that the type system alone cannot:
// src/money.gleam
pub opaque type Cents {
Cents(amount: Int)
}
pub fn from_int(amount: Int) -> Result(Cents, String) {
case amount >= 0 {
True -> Ok(Cents(amount))
False -> Error("money cannot be negative")
}
}
pub fn to_int(cents: Cents) -> Int {
cents.amount
}
pub fn add(a: Cents, b: Cents) -> Cents {
Cents(a.amount + b.amount)
}
Outside money, no one can write Cents(-5) directly - they must go through from_int, which rejects negatives. The result type is "smart": a Cents value is guaranteed non-negative everywhere it appears. Opaque types are how Gleam libraries expose safe APIs while hiding their internals.
Adding dependencies
Packages come from Hex, the package registry shared across the BEAM ecosystem (the same one Erlang and Elixir use). Add one with gleam add, which edits gleam.toml and locks the version in manifest.toml:
$ gleam add gleam_json
$ gleam add wisp # a web framework, for example
Your gleam.toml now lists the dependency:
[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
gleam_json = ">= 1.0.0 and < 2.0.0"
Commit manifest.toml so builds are reproducible. Because Gleam shares Hex with Erlang and Elixir, you can also depend on mature Erlang/Elixir libraries via Gleam's external function bindings - though for type safety you usually wrap them behind a typed Gleam interface.
Calling Erlang or JavaScript directly
Sometimes you need to reach the host platform. The @external attribute binds a Gleam function signature to an Erlang or JavaScript implementation, per target:
// bind to Erlang's :erlang.system_time/0 and to JS Date.now
@external(erlang, "erlang", "system_time")
@external(javascript, "./ffi.mjs", "now")
pub fn now() -> Int
You supply the type signature, and the compiler trusts it - this is the controlled escape hatch into the wider ecosystem on either target.
Testing with gleeunit
gleam new sets up gleeunit, the default test framework. Tests live in test/, and any public function whose name ends in _test is run automatically. Assertions are plain functions from gleeunit/should:
// test/money_test.gleam
import gleeunit
import gleeunit/should
import money
pub fn main() {
gleeunit.main()
}
pub fn from_int_rejects_negative_test() {
money.from_int(-1)
|> should.be_error
}
pub fn add_sums_amounts_test() {
let assert Ok(a) = money.from_int(100)
let assert Ok(b) = money.from_int(50)
money.add(a, b)
|> money.to_int
|> should.equal(150)
}
Run the suite with the single command:
$ gleam test
Compiling money
Compiled in 0.51s
Running money_test.main
.
.
2 tests, 0 failures
Tests run on whichever target you choose (gleam test --target javascript), which is a great way to confirm your code behaves identically on both backends.
Formatting and checking
Two more everyday commands round out the workflow:
$ gleam format # the one canonical style; usually run on save
$ gleam check # type-check fast without producing build artifacts
There is exactly one formatting style and no configuration for it, so diffs stay clean and code reviews stay about substance.
Reference
- Modules (the tour): https://tour.gleam.run/basics/modules/
- Opaque types (the tour): https://tour.gleam.run/advanced-features/opaque-types/
- Writing tests / gleeunit: https://hexdocs.pm/gleeunit/
Finally, we look at what makes Gleam a BEAM language: concurrency with actors.
Concurrency on the BEAM with Actors
Lightweight processes, message passing, and typed actors and supervisors via gleam_otp.
Concurrency on the BEAM with Actors
Gleam runs on the BEAM, the Erlang virtual machine that has powered massively concurrent, fault-tolerant systems (telephone switches, messaging backends) for decades. The BEAM's model is the actor model: many tiny, isolated processes that share nothing and communicate only by sending messages. Gleam embraces this model and adds a type-safe layer on top with the gleam_otp and gleam_erlang libraries.
Note: this lesson targets Erlang/the BEAM. On the JavaScript target there are no BEAM processes, so the actor APIs below are Erlang-only.
Processes are cheap and isolated
A BEAM process is not an OS thread - it is an extremely lightweight unit managed by the runtime. A single machine can run millions of them. Each has its own isolated heap, so one process crashing cannot corrupt another's memory; this isolation is the foundation of the BEAM's legendary reliability.
The lowest level lives in gleam/erlang/process. You can spawn a process and pass messages through a typed Subject, which is Gleam's type-safe handle for sending and receiving a particular message type:
import gleam/erlang/process
pub fn main() {
// a Subject carries messages of a chosen type - here, Int
let subject = process.new_subject()
// spawn a concurrent process that sends one message back
process.start(
running: fn() { process.send(subject, 42) },
linked: True,
)
// block until a message arrives (with a timeout in milliseconds)
let assert Ok(value) = process.receive(subject, within: 1000)
value // 42
}
The Subject(Int) type means only Int messages can be sent or received through it - the compiler rejects a process.send(subject, "oops"). This is the key difference from raw Erlang, where messages are untyped.
Actors: stateful, typed servers
Spawning bare processes is rarely how you build systems. Instead you use actors from gleam/otp/actor - long-lived processes that own some state and handle a stream of typed messages, one at a time. An actor is defined by its message type, its initial state, and an update function mapping (message, state) to a new state.
Here is a simple counter actor:
import gleam/otp/actor
import gleam/erlang/process.{type Subject}
// the messages this actor understands
pub type Message {
Increment
Decrement
GetCount(reply_to: Subject(Int))
}
// handle one message, returning the next state
fn handle(message: Message, count: Int) -> actor.Next(Message, Int) {
case message {
Increment -> actor.continue(count + 1)
Decrement -> actor.continue(count - 1)
GetCount(reply_to) -> {
process.send(reply_to, count) // reply with the current value
actor.continue(count)
}
}
}
pub fn main() {
// start the actor with initial state 0
let assert Ok(counter) = actor.start(0, handle)
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Decrement)
// ask for the count and wait for the reply
let count = actor.call(counter, GetCount, within: 1000)
count // 1
}
Because the actor processes one message at a time and owns its own state, you get safe concurrent mutation without locks or mutexes - the state lives inside the process and is never shared. actor.send is fire-and-forget; actor.call sends a message and waits for a typed reply, which is how you read state back out.
"Let it crash" and supervisors
The BEAM's most famous idea is "let it crash": rather than defensively handling every error in place, you let a faulty process die and rely on a supervisor to restart it into a known-good state. This turns transient failures into automatic recovery.
gleam/otp/supervisor lets you build typed supervision trees - a hierarchy of supervisors and workers where a parent restarts children that crash, according to a strategy you choose:
import gleam/otp/supervisor
pub fn start_tree() {
supervisor.start(fn(children) {
children
|> supervisor.add(worker_spec) // a child worker (e.g. our counter actor)
})
}
If a worker dies, the supervisor restarts it; if a whole subtree is unhealthy, the failure propagates upward and is handled at a higher level. This is the OTP (Open Telecom Platform) design that gives BEAM systems their resilience - and Gleam gives you a type-checked version of it.
Why this matters
Gleam combines two worlds that are usually separate:
- The BEAM's runtime: millions of cheap isolated processes, preemptive scheduling, message passing, hot reliability, and battle-tested OTP behaviours for actors and supervision.
- Static typing: typed messages, typed
Subjects, exhaustive handling of message variants, and no nulls or exceptions - all checked before the program runs.
The result is concurrent, fault-tolerant systems where many classes of bug are eliminated at compile time, written in a language that stays small and friendly.
Reference
- gleam_otp (actors and supervisors): https://hexdocs.pm/gleam_otp/
- gleam_erlang (processes and subjects): https://hexdocs.pm/gleam_erlang/
- Background on the BEAM and OTP: https://www.erlang.org/
That completes the tour: setup, the basics, custom types and pattern matching, error handling without nulls or exceptions, modules and testing, and BEAM concurrency. The best next step is gleam new and a small project of your own.