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

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: 1 is an Int, 1.0 is a Float.
  • String - UTF-8 text in double quotes ("hi").
  • Bool - True or False (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

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

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

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

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

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.