LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Gleam: Type Safety on the BEAM

gleambeamerlangelixirfunctional-programmingtype-safetyactor-model
Contents

The Erlang VM has a deserved reputation for indestructibility. WhatsApp ran on it at two billion users. Ericsson built the AXD301 switch — nine-nines uptime — on it in the 1990s. The BEAM’s preemptive scheduler, per-process garbage collection, and actor-model concurrency represent decades of hard-won production wisdom that no new runtime is going to replicate overnight.

The price of admission has always been a dynamic type system. Erlang and Elixir are expressive, powerful languages, but the feedback loop is runtime-first: dialyzer helps, but it’s opt-in, post-hoc analysis, not a compiler that refuses to produce a binary until your types make sense. Teams that want the BEAM’s legendary fault tolerance but also want a type system that catches the class of errors that crash production services at 3 AM have historically had no good answer.

Gleam is that answer — or the most credible attempt at one yet.

Released at version 1.0 in March 2024 after years of development by Louis Pilfold, Gleam is a statically typed functional language that compiles to Erlang bytecode (running on the BEAM) and to JavaScript (running in browsers and Node). It has Hindley-Milner type inference, sum types, exhaustive pattern matching enforced by the compiler, no null, no exceptions, and full interoperability with the existing Erlang and Elixir ecosystem. As of April 2026 it is at v1.15, actively maintained, and picking up real production users.

This post goes deep. We will cover the type system, the syntax, the use expression (Gleam’s most distinctive feature), error handling, the BEAM runtime, Erlang/Elixir interop, the JavaScript target, the tooling, building HTTP services with Wisp, and an honest look at where Gleam stands today versus the alternatives.


What Gleam Is

Gleam is a compiled, statically typed, functional language. The type system is a variant of Hindley-Milner (the same family as OCaml, Haskell, and Elm), with full type inference — you rarely need to write type annotations, the compiler figures them out. Where you do write them (on public function signatures, for documentation), the compiler verifies them.

The core pitch is not subtle: take everything the BEAM is already excellent at — concurrency, fault tolerance, distribution, hot code loading, battle-tested OTP patterns — and add a compiler that refuses to let you ship code with type errors, null pointer dereferences, unhandled error paths, or incomplete pattern matches.

1
2
3
4
5
import gleam/io

pub fn main() {
  io.println("hello, friend!")
}

That is a complete Gleam program. It compiles to Erlang, runs on the BEAM, and you get the entire OTP ecosystem for free.

The Creator and History

Louis Pilfold started Gleam around 2018. The language spent several years in pre-1.0 development, which was genuinely productive — the pre-release period was used to get the type system right, iterate on syntax, and build the toolchain. The 1.0 milestone in March 2024 was not a marketing exercise; it represented a commitment to semantic versioning and backwards compatibility. The language design, compiler, build tool, package manager, formatter, and language server all reached stability simultaneously.

Since 1.0, development has been consistent and disciplined: roughly monthly releases, each focused on either developer experience improvements (better error messages, language server features, code actions) or performance (v1.11 brought a 30% JavaScript performance improvement). The language has resisted feature bloat. By design, Gleam is a small language — the tour claims you can learn the whole thing in an afternoon. That is mostly true, and it is a deliberate choice.

The Core Value Proposition

If you are coming from Elixir, Gleam gives you:

  • A compiler that will not let you forget to handle the Error branch of a Result
  • No runtime crashes from “function clause” errors on unexpected input
  • Types that document your data structures and are verified, not just aspirational
  • Exhaustive pattern matching — add a variant to a type and the compiler tells you every case expression that needs updating

If you are coming from Haskell or OCaml, Gleam gives you:

  • The BEAM runtime — lightweight processes, preemptive scheduling, per-process GC
  • OTP supervision trees, actors, hot code reloading, distributed Erlang
  • A practical language that ships, not a research vehicle
  • Access to the Hex package ecosystem (thousands of Erlang and Elixir packages)

If you are coming from Go or Rust, Gleam gives you:

  • Functional-first programming with immutable data
  • A type system stronger than Go’s and friendlier than Rust’s
  • Concurrency through the actor model rather than goroutines or async/await
  • The same “if it compiles, it mostly works” ergonomics Rust programmers value

The Type System

Gleam’s type system is Hindley-Milner with some practical extensions. The key properties:

  • Full type inference: The compiler infers types for everything. You can write a whole module without a single annotation and the compiler will still catch type errors.
  • No implicit coercions: Int and Float are different types. There is no automatic widening or narrowing.
  • No null: The Option type represents absence. The compiler forces you to handle it.
  • No exceptions: All errors are Result values. The compiler forces you to handle them.
  • Exhaustive pattern matching: Every case expression must cover all possible inputs. The compiler rejects incomplete matches.

Primitive Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Integers — unbounded on BEAM, 64-bit on JavaScript
let age: Int = 42
let big: Int = 9_007_199_254_740_992

// Floats — 64-bit IEEE 754
// Note: float operators are distinct from int operators
let pi: Float = 3.14159
let result: Float = pi *. 2.0   // *. not *

// Strings — always UTF-8
let greeting: String = "こんにちは"

// Booleans
let flag: Bool = True
let other: Bool = False

// Nil — the unit type, represents "nothing"
// Most functions that would return void in other languages return Nil
let nothing: Nil = Nil

The float operator distinction (+., -., *., /.) looks unusual to newcomers but is deliberate: it eliminates an entire class of bugs where integer and float arithmetic get accidentally mixed. The compiler enforces it.

Custom Types (Sum Types / ADTs)

Custom types are Gleam’s primary way to model domain data. They are algebraic data types (ADTs) in the ML tradition.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// A simple enum — variants with no data
pub type Direction {
  North
  South
  East
  West
}

// Variants with data — a discriminated union / tagged union
pub type Shape {
  Circle(radius: Float)
  Rectangle(width: Float, height: Float)
  Triangle(base: Float, height: Float)
}

// Computing area — the compiler ensures all variants are handled
pub fn area(shape: Shape) -> Float {
  case shape {
    Circle(radius:) -> 3.14159 *. radius *. radius
    Rectangle(width:, height:) -> width *. height
    Triangle(base:, height:) -> base *. height /. 2.0
  }
}

If you add a Pentagon variant to Shape without updating area, the compiler will tell you exactly which case expressions need updating. This is one of the most practically valuable properties of the type system.

Record Types

Custom type variants can hold named fields, functioning like records or structs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub type User {
  User(
    id: Int,
    name: String,
    email: String,
    role: Role,
  )
}

pub type Role {
  Admin
  Member
  Guest
}

// Construction
let user = User(id: 1, name: "Alice", email: "alice@example.com", role: Admin)

// Field access
let name = user.name

// Immutable update — creates a new record with specified fields changed
let promoted = User(..user, role: Admin)

Records in Gleam are not mutable. User(..user, role: Admin) creates a new User value with all fields copied from user except role, which gets the new value. This is the standard functional programming pattern.

The Option Type

There is no null in Gleam. Absent values are represented with Option:

1
2
3
4
pub type Option(inner) {
  Some(inner)
  None
}

Option is a generic custom type. Some(42) is an Option(Int). None is an Option(a) for any a. The compiler will not let you use an Option(String) where a String is expected — you must unwrap it explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import gleam/option.{type Option, Some, None}

pub fn greet(name: Option(String)) -> String {
  case name {
    Some(n) -> "Hello, " <> n <> "!"
    None    -> "Hello, stranger!"
  }
}

// Or using option.unwrap for a default
import gleam/option

pub fn display_name(name: Option(String)) -> String {
  option.unwrap(name, or: "Anonymous")
}

The Result Type

Fallible operations return Result(ok_type, error_type):

1
2
3
4
pub type Result(value, error) {
  Ok(value)
  Error(error)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import gleam/int
import gleam/string

// int.parse returns Result(Int, Nil) — Nil means "parse failed"
pub fn parse_age(input: String) -> Result(Int, String) {
  case int.parse(input) {
    Ok(n) if n >= 0 && n <= 150 -> Ok(n)
    Ok(_) -> Error("Age must be between 0 and 150")
    Error(_) -> Error("Not a valid number: " <> input)
  }
}

Unhandled Result values cause a compiler warning. The compiler tracks whether you have checked both Ok and Error branches in your pattern matches. There is no way to silently swallow an error — you must either handle it explicitly, propagate it, or call result.unwrap with a default.

Generic Types

Gleam supports parametric polymorphism with type variables (lowercase):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// A generic pair
pub type Pair(a, b) {
  Pair(first: a, second: b)
}

// A generic stack
pub type Stack(element) {
  Stack(items: List(element))
}

pub fn push(stack: Stack(a), item: a) -> Stack(a) {
  Stack(items: [item, ..stack.items])
}

pub fn pop(stack: Stack(a)) -> Result(#(a, Stack(a)), Nil) {
  case stack.items {
    [] -> Error(Nil)
    [top, ..rest] -> Ok(#(top, Stack(items: rest)))
  }
}

The compiler infers type variable substitutions at call sites. If you call push(my_int_stack, "hello"), the compiler rejects it — the element type is already fixed to Int for that particular stack.

Type Aliases

Type aliases create alternate names for existing types. The aliased type and the original type are fully compatible:

1
2
3
4
5
6
7
8
9
pub type UserId = Int
pub type Email = String
pub type Seconds = Int

pub type Timestamp = Int  // Unix epoch seconds

pub fn token_expires_at(issued_at: Timestamp, ttl: Seconds) -> Timestamp {
  issued_at + ttl
}

Opaque Types

Opaque types hide their constructors from other modules, enabling the smart constructor pattern for maintaining invariants:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// In module positive_int.gleam
pub opaque type PositiveInt {
  PositiveInt(value: Int)
}

pub fn new(i: Int) -> Result(PositiveInt, String) {
  case i > 0 {
    True  -> Ok(PositiveInt(i))
    False -> Error("Must be positive, got: " <> int.to_string(i))
  }
}

pub fn value(p: PositiveInt) -> Int {
  p.value
}

pub fn add(a: PositiveInt, b: PositiveInt) -> PositiveInt {
  PositiveInt(a.value + b.value)
}

External code can only create PositiveInt through the new constructor, which validates the invariant. The compiler prevents bypassing it.


Syntax and Core Language

Gleam’s syntax is ML-inspired, leaning toward the Rust/OCaml end of the family rather than Haskell’s. Braces instead of significant whitespace, fn for functions, let for bindings. If you have written OCaml, F#, or Elm, it will feel immediately familiar.

Let Bindings

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Basic binding
let x = 42

// With type annotation (optional, compiler verifies if present)
let name: String = "Alice"

// Bindings are immutable — but you can shadow them
let x = 10
let x = x + 1  // x is now 11 — this is a new binding, not mutation

// Discard unused values
let _unused = some_side_effectful_function()

// Destructuring in let
let #(first, second) = #("hello", 42)
let [head, ..tail] = [1, 2, 3, 4]

The “shadowing is allowed but mutation is not” model takes a few days to internalize if you come from imperative languages. Once it clicks, the constraint is freeing — you can always reason about a binding’s value by looking at where it was created.

Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Basic function
fn add(a: Int, b: Int) -> Int {
  a + b
}

// Public function
pub fn greet(name: String) -> String {
  "Hello, " <> name <> "!"
}

// Anonymous function (lambda)
let double = fn(x: Int) -> Int { x * 2 }

// Anonymous function with inferred types
let square = fn(x) { x * x }

// Higher-order function
pub fn apply_twice(f: fn(Int) -> Int, x: Int) -> Int {
  f(f(x))
}

// Calling it
let result = apply_twice(double, 3)  // 12

// Function capture — partial application with _
let add_five = add(5, _)
let result = add_five(3)  // 8

Functions are first-class values. They can be passed to and returned from other functions. Function types are written fn(arg_types) -> return_type.

Labeled Arguments

Gleam supports labeled arguments, which improve call-site readability for functions with multiple parameters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub fn send_email(
  to recipient: String,
  from sender: String,
  subject subject_line: String,
  body content: String,
) -> Result(Nil, String) {
  // ...
  Ok(Nil)
}

// Called with labels (labels can be in any order)
send_email(
  to: "alice@example.com",
  from: "noreply@example.com",
  subject: "Welcome",
  body: "Thanks for signing up!",
)

When the variable name at the call site matches the label, you can use shorthand:

1
2
3
4
5
6
7
let to = "alice@example.com"
let from = "noreply@example.com"
let subject = "Welcome"
let body = "Thanks for signing up!"

// Shorthand — no need to repeat the name
send_email(to:, from:, subject:, body:)

The Pipeline Operator

|> passes the result of the left expression as the first argument to the right function. It is the idiomatic way to chain transformations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import gleam/string
import gleam/list

// Without pipelines — read inside-out
let result = string.trim(string.uppercase(string.replace(input, each: "-", with: " ")))

// With pipelines — read left to right
let result =
  input
  |> string.replace(each: "-", with: " ")
  |> string.uppercase
  |> string.trim

// Processing a list
let total =
  [1, 2, 3, 4, 5]
  |> list.filter(fn(x) { x > 2 })
  |> list.map(fn(x) { x * 10 })
  |> list.fold(0, fn(acc, x) { acc + x })
// total == 120

The pipeline operator is syntactic sugar — a |> f(b) desugars to f(a, b) where a is inserted as the first argument. The Gleam compiler will try the pipeline as first argument first, and if that doesn’t type-check, it tries it as a standalone call (for functions that take one argument).

Pattern Matching with case

case is Gleam’s primary control flow mechanism. It is not an afterthought — it is the main way you make decisions in Gleam code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Basic case
pub fn classify_number(n: Int) -> String {
  case n {
    0 -> "zero"
    1 -> "one"
    _ -> "many"
  }
}

// Pattern matching on custom types
pub fn describe_shape(shape: Shape) -> String {
  case shape {
    Circle(radius:) ->
      "A circle with radius " <> float.to_string(radius)
    Rectangle(width:, height:) ->
      "A " <> float.to_string(width) <> "x" <> float.to_string(height) <> " rectangle"
    Triangle(base:, height:) ->
      "A triangle with base " <> float.to_string(base)
  }
}

// Guards — additional conditions on patterns
pub fn describe_int(n: Int) -> String {
  case n {
    n if n < 0  -> "negative"
    0           -> "zero"
    n if n > 100 -> "large positive"
    _           -> "small positive"
  }
}

// Multiple subjects
pub fn fizzbuzz(n: Int) -> String {
  case n % 3, n % 5 {
    0, 0 -> "FizzBuzz"
    0, _ -> "Fizz"
    _, 0 -> "Buzz"
    _, _ -> int.to_string(n)
  }
}

// Alternative patterns with |
pub fn is_weekend(day: Day) -> Bool {
  case day {
    Saturday | Sunday -> True
    _ -> False
  }
}

// List patterns
pub fn sum(numbers: List(Int)) -> Int {
  case numbers {
    [] -> 0
    [first, ..rest] -> first + sum(rest)
  }
}

// As patterns — bind the whole matched value
pub fn get_first_nonempty(lists: List(List(a))) -> Result(List(a), Nil) {
  case lists {
    [] -> Error(Nil)
    [[_, ..] as nonempty, ..] -> Ok(nonempty)
    [_, ..rest] -> get_first_nonempty(rest)
  }
}

The compiler guarantees exhaustiveness. If you remove a branch, add a new custom type variant, or write a guard that leaves gaps, the compiler tells you at build time.

String Interpolation

Gleam added string interpolation in a relatively recent release:

1
2
3
4
5
6
7
8
9
let name = "Alice"
let age = 30

// String interpolation with {}
let message = "Hello, {name}! You are {age} years old."

// For non-string types, they must be converted to strings first
let count = 42
let status = "Found {int.to_string(count)} items"

For older patterns (pre-interpolation), the <> concatenation operator and string.concat are used:

1
let message = "Hello, " <> name <> "! You are " <> int.to_string(age) <> " years old."

Blocks

Expressions can be grouped into blocks. The last expression in a block is the block’s value:

1
2
3
4
5
let result = {
  let x = expensive_computation()
  let y = another_computation(x)
  x + y  // This is the value of the block
}

Blocks are how you sequence multiple operations where only a single expression is syntactically expected.

Recursion and Tail Calls

Gleam has no loops — iteration is done through recursion. The compiler optimizes tail-recursive functions into loops, so you can recurse over large data structures without stack overflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Non-tail-recursive — builds up stack frames
pub fn sum_naive(numbers: List(Int)) -> Int {
  case numbers {
    [] -> 0
    [first, ..rest] -> first + sum_naive(rest)  // + happens after recursive call
  }
}

// Tail-recursive with accumulator — constant stack space
pub fn sum(numbers: List(Int)) -> Int {
  sum_loop(numbers, 0)
}

fn sum_loop(numbers: List(Int), acc: Int) -> Int {
  case numbers {
    [] -> acc
    [first, ..rest] -> sum_loop(rest, acc + first)  // recursive call is the last thing
  }
}

In practice, most list operations are handled by gleam/listmap, filter, fold, find, sort — all of which are already tail-recursive. You write your own loops mainly when the standard library doesn’t cover your specific traversal pattern.


The use Expression

The use expression is Gleam’s most distinctive feature, and probably the most important one to understand if you are coming from another language. It is worth spending time on.

The Problem: Callback Hell in a Typed Language

Gleam has no exceptions, no early returns, and no do-notation. Pure functional control flow means nested callbacks. Consider a realistic request handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Without use — deeply nested
pub fn handle_create_user(request: Request) -> Response {
  logger.with_span("create_user", fn() {
    database.with_connection(fn(conn) {
      case wisp.require_method(request, Post) {
        Ok(_) ->
          case wisp.require_json(request) {
            Ok(json) ->
              case decode_user_input(json) {
                Ok(input) ->
                  case db.insert_user(conn, input) {
                    Ok(user) -> created_response(user)
                    Error(err) -> handle_db_error(err)
                  }
                Error(validation_err) -> bad_request_response(validation_err)
              }
            Error(_) -> wisp.unsupported_media_type(["application/json"])
          }
        Error(_) -> wisp.method_not_allowed([Post])
      }
    })
  })
}

This is syntactically correct Gleam. It is also unreadable. Every early-exit path requires another level of nesting.

The Solution: use Desugars Callbacks

use is syntactic sugar. The expression:

1
2
use variable <- some_function(args)
rest_of_block

desugars to:

1
2
3
some_function(args, fn(variable) {
  rest_of_block
})

The key insight: everything that comes after the use expression in the same block becomes the body of an anonymous function passed as the last argument to some_function. This means use works with any function that takes a callback as its last argument — no magic, no special protocol.

Here is the same handler rewritten with use:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// With use — reads like imperative code, is purely functional
pub fn handle_create_user(request: Request) -> Response {
  use <- logger.with_span("create_user")
  use conn <- database.with_connection()
  use <- wisp.require_method(request, Post)
  use json <- wisp.require_json(request)
  use input <- result.try(decode_user_input(json))

  case db.insert_user(conn, input) {
    Ok(user) -> created_response(user)
    Error(err) -> handle_db_error(err)
  }
}

Same semantics, radically different readability. The first version hides the happy path deep in nested callbacks. The second version reads like a linear sequence of steps with early-exit behavior handled by the callback structure.

use with Result Chaining

The most common use of use in practice is chaining Result-returning operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import gleam/result

pub fn process_order(order_id: String) -> Result(Invoice, AppError) {
  use order   <- result.try(fetch_order(order_id))
  use items   <- result.try(fetch_order_items(order.id))
  use pricing <- result.try(calculate_pricing(items))
  use invoice <- result.try(create_invoice(order, pricing))

  Ok(invoice)
}

result.try has the signature fn(Result(a, e), fn(a) -> Result(b, e)) -> Result(b, e). If the result is Ok(value), it calls the callback with value. If it is Error(e), it short-circuits and returns Error(e). Combined with use, this gives you Haskell’s do-notation for Result without needing monads as a language concept.

Each step either succeeds and passes its value to the next step, or fails and returns the error immediately. The error type must be consistent across the chain (or you need result.map_error to align them).

use for Resource Management

use is also perfect for resource management — the callback pattern ensures cleanup runs regardless of what happens in the body:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Define a resource-managing function
pub fn with_database_transaction(
  conn: Connection,
  work: fn(Transaction) -> Result(a, DbError),
) -> Result(a, DbError) {
  let tx = begin_transaction(conn)
  let result = work(tx)
  case result {
    Ok(_) -> commit(tx)
    Error(_) -> rollback(tx)
  }
  result
}

// Use it
pub fn transfer_funds(
  conn: Connection,
  from: AccountId,
  to: AccountId,
  amount: Decimal,
) -> Result(Nil, TransferError) {
  use tx <- with_database_transaction(conn)
  use source_balance <- result.try(get_balance(tx, from))
  use _ <- result.try(check_sufficient_funds(source_balance, amount))
  use _ <- result.try(debit_account(tx, from, amount))
  use _ <- result.try(credit_account(tx, to, amount))
  Ok(Nil)
}

The transaction commits or rolls back automatically based on whether the block returns Ok or Error. No try/finally, no defer, no explicit cleanup — the callback pattern handles it.

use with Other Patterns

use works with any function that takes a callback as its last argument. This makes it universally applicable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// use with Option chaining
import gleam/option.{type Option}

fn option_try(opt: Option(a), f: fn(a) -> Option(b)) -> Option(b) {
  case opt {
    option.Some(value) -> f(value)
    option.None -> option.None
  }
}

pub fn find_user_email(users: List(User), user_id: Int) -> Option(String) {
  use user  <- option_try(list.find(users, fn(u) { u.id == user_id }))
  use email <- option_try(user.email)  // email is Option(String)
  option.Some(email)
}

// use with Wisp middleware (common pattern)
pub fn handle_request(req: Request, ctx: Context) -> Response {
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use <- wisp.serve_static(req, under: "/static", from: ctx.static_dir)

  case wisp.path_segments(req) {
    ["api", "users"] -> handle_users(req, ctx)
    ["api", "users", id] -> handle_user(req, ctx, id)
    _ -> wisp.not_found()
  }
}

How use Compares to Other Languages

Language Feature Notes
Haskell do-notation Requires monad typeclass, type class system
F# / OCaml Computation expressions / let* Similar desugaring, requires special syntax
Rust ? operator Only for Result/Option, not general
JavaScript async/await Only for Promises
Gleam use Works with any callback-taking function, no typeclasses needed

Gleam’s use is more general than ? (works for any callback pattern) but more explicit than do-notation (the callback structure is visible in the desugaring). It is a pragmatic middle ground that works well in practice.


Error Handling

Gleam’s error handling philosophy: errors are values. There are no exceptions — not even for “exceptional” circumstances. If an operation can fail, it returns Result(success, failure). The type system forces you to decide what to do with failures.

The Result Type in Practice

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import gleam/int
import gleam/result

pub type AppError {
  DatabaseError(message: String)
  ValidationError(field: String, message: String)
  NotFoundError(resource: String, id: String)
  AuthorizationError
}

// A function that can fail
pub fn find_user(id: String) -> Result(User, AppError) {
  case db.query("SELECT * FROM users WHERE id = $1", [id]) {
    Ok([row, ..]) -> Ok(row_to_user(row))
    Ok([])        -> Error(NotFoundError(resource: "user", id: id))
    Error(msg)    -> Error(DatabaseError(message: msg))
  }
}

// Handling it at the call site
pub fn get_user_name(id: String) -> Result(String, AppError) {
  case find_user(id) {
    Ok(user)  -> Ok(user.name)
    Error(err) -> Error(err)  // Propagate the error
  }
}

// More idiomatically — use result.map to transform the Ok value
pub fn get_user_name(id: String) -> Result(String, AppError) {
  result.map(find_user(id), fn(user) { user.name })
}

// Even more idiomatically — use pipelines
pub fn get_user_name(id: String) -> Result(String, AppError) {
  find_user(id)
  |> result.map(fn(user) { user.name })
}

The gleam/result Module

The standard library’s result module provides a comprehensive set of combinators:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import gleam/result

// map: transform the Ok value, leave Error unchanged
result.map(Ok(42), fn(x) { x * 2 })     // Ok(84)
result.map(Error("oops"), fn(x) { x })   // Error("oops")

// map_error: transform the Error value, leave Ok unchanged
result.map_error(Error("db error"), DatabaseError)  // Error(DatabaseError("db error"))

// try: chain Result-returning functions
result.try(Ok(42), fn(x) { Ok(x * 2) })    // Ok(84)
result.try(Ok(42), fn(x) { Error("nope") }) // Error("nope")
result.try(Error("e"), fn(x) { Ok(x) })     // Error("e")

// unwrap: extract Ok value or return default
result.unwrap(Ok(42), or: 0)      // 42
result.unwrap(Error("e"), or: 0)  // 0

// unwrap_error: extract Error value or return default
result.unwrap_error(Error("e"), or: "")  // "e"
result.unwrap_error(Ok(42), or: "")      // ""

// is_ok, is_error
result.is_ok(Ok(42))      // True
result.is_error(Error("")) // True

// or: return first Ok, or second value
result.or(Ok(1), Ok(2))     // Ok(1)
result.or(Error(""), Ok(2)) // Ok(2)

// all: combines a list of Results
result.all([Ok(1), Ok(2), Ok(3)])        // Ok([1, 2, 3])
result.all([Ok(1), Error("e"), Ok(3)])   // Error("e")

// values: extract all Ok values, silently drop Errors
result.values([Ok(1), Error("e"), Ok(3)])  // [1, 3]

// partition: split into successes and failures
result.partition([Ok(1), Error("e"), Ok(3)])  // #([1, 3], ["e"])

Error Propagation with use

The pattern for propagating errors up a call stack without writing Error(err) -> Error(err) repeatedly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import gleam/result

pub fn create_account(
  email: String,
  password: String,
) -> Result(Account, AppError) {
  // Each use-line: if result is Error, entire function returns that Error
  use validated_email    <- result.try(validate_email(email))
  use validated_password <- result.try(validate_password(password))
  use hashed_password    <- result.try(hash_password(validated_password))

  use existing <- result.try(check_email_not_taken(validated_email))
  case existing {
    True  -> Error(ValidationError(field: "email", message: "Already registered"))
    False -> create_user_record(validated_email, hashed_password)
  }
}

This is clean. The happy path reads top-to-bottom. Errors short-circuit immediately. No nesting.

When Errors Must Be Final: let assert and panic

Sometimes you genuinely know at a certain point that a value must be in a particular shape — you have already validated it elsewhere, or you are in a test, or you are writing a bootstrap function where failure is unrecoverable. Gleam provides escape hatches:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// let assert — panics at runtime if pattern doesn't match
// Use sparingly, document why it's safe
let assert Ok(conn) = database.connect(config)
  as "Database connection must succeed at startup"

// panic — explicit crash
fn handle_unknown_state(state: AppState) -> Nil {
  panic as "Reached impossible state: " <> inspect(state)
}

// todo — marks unfinished code (panics when reached)
pub fn handle_webhook(event: WebhookEvent) -> Result(Nil, String) {
  case event {
    PaymentCompleted(id) -> process_payment(id)
    RefundRequested(id)  -> todo as "Refund handling not yet implemented"
  }
}

let assert and panic are deliberate escape hatches, not routine error handling. The convention is to use them only where you can document why the assertion is guaranteed to hold, or where a crash is genuinely the correct behavior (unrecoverable initialization failure, test assertion, logic invariant violation).

Why No Exceptions?

The practical argument: exceptions create implicit control flow paths that are invisible in function signatures. A function that raises an exception is not distinguishable from one that does not by looking at its type. This means callers cannot know which operations can fail, which creates hidden failure modes that show up in production.

Result makes failure explicit in the type. If a function returns Result(User, DbError), you know it can fail, you know what it can fail with, and the compiler makes you handle both cases. The codebase becomes self-documenting about its failure modes.


The BEAM Runtime

Understanding what Gleam gets “for free” from targeting the BEAM requires understanding what the BEAM actually is.

The BEAM Is Not an Ordinary VM

The BEAM (Bogdan/Björn’s Erlang Abstract Machine) was designed from the ground up for telecommunications systems — specifically, Ericsson’s telephone exchanges, which required:

  • No downtime: Systems had to keep running even during software upgrades
  • Massive concurrency: Millions of simultaneous phone calls
  • Fault isolation: One bad call should not crash the exchange
  • Predictable latency: No multi-second GC pauses

Every design decision in the BEAM flows from these requirements, and they happen to map perfectly onto modern distributed web services.

Preemptive Scheduling

The BEAM uses a preemptive scheduler, not a cooperative one. Every Erlang/Gleam process gets a limited number of “reductions” (roughly, function calls) before the scheduler preempts it and runs something else. Processes cannot monopolize CPU time.

This is fundamentally different from Go’s goroutines (cooperative until 1.14, and still partially cooperative), Node.js’s event loop (cooperative, a blocking operation blocks everything), or Python’s asyncio (cooperative). On the BEAM, if one process goes into an infinite loop, all other processes continue running normally. The misbehaving process just gets CPU-starved or killed.

Traditional Thread Model:
Thread 1: ████████████████████────────────────
Thread 2: ────────────────────████████████████
(one blocks, the other waits)

BEAM Scheduler (N schedulers for N CPU cores):
Scheduler 1: P1 P2 P3 P4 P1 P2 P3 P4 P5 P1...
Scheduler 2: P6 P7 P8 P9 P6 P7 P8 P9 P6 P7...
(each scheduler round-robins hundreds of processes)

Lightweight Processes (Actors)

BEAM processes are not OS threads. A fresh BEAM process starts with a ~2KB heap. A modern system can run millions of them simultaneously. The overhead per process is so low that the idiomatic pattern is to create a process per connection, per user session, per request — whatever the natural unit of work is.

This is the actor model: each process has private state, communicates only through message passing, and can only affect other processes by sending messages. There is no shared mutable state between processes (though within a process you can use mutable state through the process’s own state mechanism).

1
2
3
4
5
6
7
8
// Spawn a new process
import gleam/erlang/process

// Simple fire-and-forget process
let _pid = process.start(fn() {
  // This runs in its own isolated process
  do_background_work()
}, link: False)

Per-Process Garbage Collection

Each BEAM process has its own heap and garbage collector. When a process is done (or killed), its entire heap is reclaimed in one step — no global GC pause. When a process is active but idle (waiting for a message), it is not scanned by the GC at all.

This gives the BEAM a unique property: under load, GC pauses do not increase globally. More active processes means more total GC work, but the pauses per process stay small and bounded. The “GC pause at the worst possible moment” problem that plagues JVM applications is structurally absent.

OTP: The Framework for Reliable Systems

OTP (Open Telecom Platform) is the set of libraries and design patterns built on top of the BEAM’s process model. It provides:

  • GenServer / Actor: A generic server pattern that handles OTP system messages, hot code upgrades, and debugging hooks
  • Supervisor: Processes that watch other processes and restart them when they crash
  • Application: The top-level abstraction for a running OTP application with its own supervision tree
  • Registry: Named process lookup without passing PIDs around

The key OTP insight is let it crash: instead of defensive programming (catching every possible error, validating all inputs, handling every edge case), you write the happy path and let supervisors handle failures by restarting the failed process in a known-good state.

Supervision Tree Example:
Application
├── DatabaseSupervisor
│   ├── ConnectionPool (restarts on crash, replaces broken connections)
│   └── MigrationRunner (runs once, exits normally)
├── WebSupervisor
│   ├── HttpListener (restarts on crash)
│   └── RequestHandlerPool
│       ├── Handler_1 (restarts on crash, independent)
│       ├── Handler_2 (restarts on crash, independent)
│       └── Handler_N...
└── BackgroundSupervisor
    ├── EmailWorker (restarts on crash)
    └── MetricsReporter (restarts on crash)

A crash in Handler_2 kills that process and the supervisor restarts it. Handler_1 keeps running. The database connection is unaffected. The system degrades gracefully under partial failure rather than crashing entirely.

What Gleam Gets From the BEAM

All of this — preemptive scheduling, lightweight processes, per-process GC, OTP supervision — is available to Gleam programs. A Gleam program targeting Erlang compiles to BEAM bytecode and runs on the BEAM VM. It is interoperable with Erlang and Elixir code at the process level. It participates in supervision trees. It can be distributed across nodes.

The type safety Gleam adds does not come at the cost of runtime characteristics. A Gleam program is not slower than an equivalent Erlang program — the compiled output is just Erlang (or BEAM bytecode).


The gleam_otp Library

Gleam provides typed OTP abstractions through the gleam_otp package. The API is type-safe but semantically equivalent to Erlang’s OTP.

Building Typed Actors

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import gleam/erlang/process.{type Subject}
import gleam/otp/actor

// Define the message type for your actor
// This is the complete API — no other messages can be sent
pub type CounterMessage {
  Increment(by: Int)
  Decrement(by: Int)
  GetValue(reply_with: Subject(Int))
  Reset
  Shutdown
}

// The handler function — called for each message
fn handle_message(
  message: CounterMessage,
  state: Int,
) -> actor.Next(CounterMessage, Int) {
  case message {
    Increment(by:) ->
      actor.continue(state + by)

    Decrement(by:) ->
      actor.continue(state - by)

    GetValue(reply_with:) -> {
      process.send(reply_with, state)
      actor.continue(state)
    }

    Reset ->
      actor.continue(0)

    Shutdown ->
      actor.stop()
  }
}

// Start the actor
pub fn start_counter() -> Result(Subject(CounterMessage), actor.StartError) {
  actor.new(0)
  |> actor.on_message(handle_message)
  |> actor.start
}

// Use the actor
pub fn main() {
  let assert Ok(counter) = start_counter()

  // Fire and forget
  process.send(counter, Increment(by: 5))
  process.send(counter, Increment(by: 3))

  // Request-reply (synchronous call)
  let value = actor.call(counter, GetValue, 1000)
  // value == 8

  process.send(counter, Shutdown)
}

The type system ensures you can only send CounterMessage values to this actor. Send the wrong type? Compiler error at the call site.

Supervision Trees

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import gleam/otp/supervisor
import gleam/otp/actor

pub fn start_supervision_tree() {
  let children = [
    supervisor.worker(fn(_) { start_counter() }),
    supervisor.worker(fn(_) { start_email_worker() }),
    supervisor.worker(fn(_) { start_metrics_reporter() }),
  ]

  supervisor.start(supervisor.new(children))
}

When any of those workers crashes, the supervisor automatically restarts it. The restart strategy (one-for-one, one-for-all, rest-for-one) determines how failures propagate.


Erlang and Elixir Interoperability

One of Gleam’s most significant practical advantages is its interoperability with the existing Erlang ecosystem. You are not starting from scratch — you have access to two decades of battle-tested libraries.

The @external Attribute

Foreign function interface (FFI) in Gleam is done with @external. It maps a Gleam function signature to an Erlang function:

1
2
3
4
5
6
// Map Gleam's crypto_random_bytes to Erlang's crypto:strong_rand_bytes/1
@external(erlang, "crypto", "strong_rand_bytes")
pub fn crypto_random_bytes(size: Int) -> BitArray

// Use it like any Gleam function — the type system applies
let random_bytes = crypto_random_bytes(32)

The first string argument to @external(erlang, ...) is the Erlang module name, the second is the function name. The arity is inferred from the Gleam function signature.

1
2
3
4
5
6
// Calling Erlang's calendar module
@external(erlang, "calendar", "local_time")
pub fn local_time() -> #(#(Int, Int, Int), #(Int, Int, Int))

// This returns a tuple-of-tuples: {{year, month, day}, {hour, min, sec}}
let #(#(year, month, day), #(hour, min, sec)) = local_time()

The gleam/erlang Standard Package

The gleam_erlang package provides typed bindings to common Erlang standard library functionality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import gleam/erlang
import gleam/erlang/atom
import gleam/erlang/process

// Atoms — Erlang's atom type with Gleam types
let ok_atom = atom.create_from_string("ok")

// Process operations
let self = process.self()
let pid = process.start(fn() { worker_loop() }, link: True)

// Crash the current process (triggers supervisor restart)
process.kill(self)

// Sleep (in milliseconds)
erlang.sleep(100)

// Get all running process PIDs (useful for debugging/monitoring)
let all_pids = erlang.process_list()

Calling Elixir from Gleam

Elixir code compiles to BEAM bytecode and is callable from Gleam using @external. The naming convention: Elixir modules are prefixed with Elixir. in the BEAM namespace:

1
2
3
4
5
6
7
// Calling Elixir's Jason JSON library
@external(erlang, "Elixir.Jason", "encode!")
fn jason_encode(value: Dynamic) -> String

// Using Elixir's Ecto (if your project has it as a dependency)
@external(erlang, "Elixir.Repo", "get")
fn ecto_get(schema: atom.Atom, id: Int) -> Dynamic

In practice, calling Elixir from Gleam is more common in hybrid projects where you are gradually introducing Gleam into an existing Elixir codebase. For new projects, you would use Gleam-native libraries where they exist.

Using Hex Packages from Erlang/Elixir

Since Gleam uses Hex (the Erlang/Elixir package registry), you can add Erlang packages directly to your gleam.toml:

1
2
3
4
5
6
7
[dependencies]
gleam_stdlib = ">= 0.71.0 and < 1.0.0"
gleam_erlang = ">= 1.0.0 and < 2.0.0"

# Direct Erlang library dependency
hackney = ">= 1.20.1 and < 2.0.0"   # HTTP client
jsx = ">= 3.1.0 and < 4.0.0"         # JSON library

Then wrap the Erlang functions with Gleam types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Wrapping hackney HTTP client
@external(erlang, "hackney", "get")
fn hackney_get(url: String, headers: List(#(String, String)), body: String, opts: List(Dynamic)) -> Dynamic

pub fn http_get(url: String) -> Result(String, String) {
  case hackney_get(url, [], "", []) {
    // Pattern match on the Dynamic return value
    // ...
  }
}

For HTTP clients specifically, the Gleam ecosystem has its own typed alternatives (gleam_http, gleam_fetch) that are more idiomatic.

Multi-Target Externals

When writing code that must run on both BEAM and JavaScript, you can provide target-specific implementations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Different implementations per runtime
@external(erlang, "erlang", "monotonic_time")
@external(javascript, "./time_ffi.mjs", "monotonicTime")
pub fn monotonic_time_milliseconds() -> Int

// Or: provide a Gleam fallback for one target, optimized external for another
@external(erlang, "lists", "reverse")
pub fn reverse_list(items: List(e)) -> List(e) {
  // This Gleam implementation runs on JavaScript
  // The Erlang external runs on BEAM (optimized C implementation)
  do_reverse(items, [])
}

fn do_reverse(items: List(e), acc: List(e)) -> List(e) {
  case items {
    [] -> acc
    [first, ..rest] -> do_reverse(rest, [first, ..acc])
  }
}

The JavaScript Target

Gleam compiles to both Erlang bytecode and JavaScript. This is a first-class target, not an afterthought — the standard library works on both targets, and the type system applies equally.

Why JavaScript?

The JavaScript target unlocks two use cases:

  1. Full-stack Gleam: Share types and business logic between backend (BEAM) and frontend (browser). Define your domain types once, use them on both sides.
  2. JavaScript-first environments: Some teams want Gleam’s type system but are committed to Node.js or Deno for infrastructure reasons. The JS target supports this.

Setting the Target

1
2
3
# gleam.toml — default target
[gleam]
target = "erlang"  # or "javascript"

You can also specify target per-build:

1
2
3
gleam build --target javascript
gleam run --target javascript
gleam test --target javascript

JavaScript FFI

When targeting JavaScript, @external maps to JavaScript modules:

1
2
3
4
5
6
7
8
9
// External JavaScript function
@external(javascript, "./dom_ffi.mjs", "getElementById")
pub fn get_element_by_id(id: String) -> Result(Element, Nil)

@external(javascript, "./storage_ffi.mjs", "getItem")
pub fn local_storage_get(key: String) -> Result(String, Nil)

@external(javascript, "./storage_ffi.mjs", "setItem")
pub fn local_storage_set(key: String, value: String) -> Nil

The FFI module is a standard ES module:

1
2
3
4
5
6
7
8
// dom_ffi.mjs
export function getElementById(id) {
  const el = document.getElementById(id);
  if (el === null) {
    return { 0: "Error", 1: undefined };  // Gleam's Error(Nil)
  }
  return { 0: "Ok", 1: el };  // Gleam's Ok(element)
}

TypeScript Definitions

Gleam generates TypeScript .d.ts files alongside the compiled JavaScript. This means TypeScript code can safely consume Gleam modules with full type information:

1
2
3
4
gleam build --target javascript
# Generates:
# build/dev/javascript/my_app/my_module.mjs
# build/dev/javascript/my_app/my_module.d.mts
1
2
3
4
// TypeScript consuming Gleam
import { greet } from "./my_module.d.mts";

const message: string = greet("Alice");  // Fully typed

Lustre: Frontend Framework

The primary frontend framework for Gleam is Lustre, which implements The Elm Architecture (TEA) — the same architecture that Redux was inspired by:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import lustre
import lustre/element.{type Element}
import lustre/element/html
import lustre/event

// Model — application state
pub type Model {
  Model(count: Int)
}

// Initial state
pub fn init(_flags) -> Model {
  Model(count: 0)
}

// Messages — things that can happen
pub type Msg {
  Increment
  Decrement
  Reset
}

// Update — pure function: (model, message) -> new model
pub fn update(model: Model, msg: Msg) -> Model {
  case msg {
    Increment -> Model(count: model.count + 1)
    Decrement -> Model(count: model.count - 1)
    Reset -> Model(count: 0)
  }
}

// View — pure function: model -> HTML
pub fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.h1([], [element.text("Counter")]),
    html.p([], [element.text("Count: " <> int.to_string(model.count))]),
    html.button([event.on_click(Increment)], [element.text("+")]),
    html.button([event.on_click(Decrement)], [element.text("-")]),
    html.button([event.on_click(Reset)],     [element.text("Reset")]),
  ])
}

// Wire it together
pub fn main() {
  let app = lustre.simple(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)
}

Lustre also supports server-side rendering and real-time server components, making it viable for both SPA and SSR use cases.

Shared Code Between BEAM and Browser

The ability to share types across targets is one of Gleam’s most compelling practical features:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// shared/types.gleam — compiled to both Erlang and JavaScript
pub type User {
  User(id: Int, name: String, email: String, role: Role)
}

pub type Role {
  Admin
  Member
  Guest
}

pub type ApiError {
  NotFound
  Unauthorized
  ValidationFailed(errors: List(FieldError))
  InternalError(message: String)
}

pub type FieldError {
  FieldError(field: String, message: String)
}

// shared/api.gleam — API request/response types
pub type CreateUserRequest {
  CreateUserRequest(name: String, email: String, password: String)
}

pub type CreateUserResponse {
  CreateUserResponse(user: User, token: String)
}

The backend validates and constructs these types. The frontend decodes API responses into the same types. If the API contract changes, both break at compile time.


Ecosystem and Tooling

Gleam has a well-designed, batteries-included toolchain. A single binary handles everything.

Project Creation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Create a new project
gleam new my_service

# Project structure:
# my_service/
# ├── gleam.toml       — project configuration
# ├── README.md
# ├── .gitignore
# ├── .github/
# │   └── workflows/
# │       └── test.yml  — CI workflow
# ├── src/
# │   └── my_service.gleam  — main module
# └── test/
#     └── my_service_test.gleam
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# gleam.toml
name = "my_service"
version = "0.1.0"
target = "erlang"

[dependencies]
gleam_stdlib = ">= 0.71.0 and < 1.0.0"

[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

Core Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Add a dependency (fetches from Hex, updates gleam.toml and manifest.toml)
gleam add wisp
gleam add gleam_json
gleam add --dev gleeunit  # dev-only dependency

# Build
gleam build                          # Compile for default target
gleam build --target javascript      # Compile for JavaScript

# Run
gleam run                            # Run main function
gleam run -m my_service/web          # Run specific module's main
gleam run --target javascript        # Run on Node.js

# Test
gleam test                           # Run all tests
gleam test --target javascript       # Run tests on Node.js

# Format (like gofmt — opinionated, no configuration)
gleam format                         # Format all .gleam files
gleam format --check                 # Check formatting without modifying

# Language server (used by editor integrations)
gleam lsp

# Documentation
gleam docs build                     # Generate HTML docs
gleam docs publish                   # Publish to HexDocs

# Package publishing
gleam publish                        # Publish to Hex

manifest.toml

manifest.toml is the lockfile — pinned exact versions of all dependencies, transitive included. Commit it. Reproducible builds across developer machines and CI.

1
2
3
4
5
6
7
# manifest.toml (auto-generated, commit this)
packages = [
  { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], ... },
  { name = "wisp", version = "2.2.2", build_tools = ["gleam"], ... },
  { name = "mist", version = "6.0.2", build_tools = ["gleam"], ... },
  # ... transitive dependencies
]

Testing with Gleeunit

The default test framework is gleeunit, which wraps EUnit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// test/my_service_test.gleam
import gleeunit
import gleeunit/should
import my_service/math

pub fn main() {
  gleeunit.main()
}

// Test functions must end in _test
pub fn addition_test() {
  math.add(2, 3)
  |> should.equal(5)
}

pub fn empty_list_sum_test() {
  math.sum([])
  |> should.equal(0)
}

pub fn result_handling_test() {
  math.parse_positive("42")
  |> should.be_ok
  |> should.equal(42)

  math.parse_positive("-1")
  |> should.be_error
}

Key Ecosystem Packages

Web Servers and Frameworks:

Package Description
wisp Practical HTTP framework for backend services
mist Low-level HTTP server (Wisp’s underlying server)
lustre Frontend framework implementing TEA; also does SSR
dream Alternative web framework

Data and Encoding:

Package Description
gleam_json JSON encoding/decoding
decode Composable decoders for dynamic data
gleam_http HTTP types (shared between client and server)
gleam_fetch HTTP client for JavaScript target

Database:

Package Description
squirrel Type-safe SQL queries (generates Gleam code from SQL)
postgleam Native PostgreSQL driver

Testing:

Package Description
gleeunit Default test runner wrapping EUnit
birdie Snapshot testing

OTP and Concurrency:

Package Description
gleam_otp Typed actors, supervisors, tasks
gleam_erlang BEAM process, atom, port abstractions

Building HTTP Services with Wisp

Wisp is the go-to HTTP framework for Gleam backend services. It is practical, not magical — built on Mist (a pure-Gleam HTTP server built on Mist, which wraps ranch, the Erlang TCP server). Let’s build a complete API service.

Project Setup

1
2
3
4
gleam new bookmarks_api
cd bookmarks_api
gleam add wisp mist gleam_erlang gleam_json
gleam add --dev gleeunit
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# gleam.toml
name = "bookmarks_api"
version = "0.1.0"
target = "erlang"

[dependencies]
gleam_stdlib = ">= 0.71.0 and < 1.0.0"
gleam_erlang = ">= 1.0.0 and < 2.0.0"
wisp = ">= 2.0.0 and < 3.0.0"
mist = ">= 6.0.0 and < 7.0.0"
gleam_json = ">= 2.0.0 and < 3.0.0"

[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

Domain Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/bookmarks_api/types.gleam
import gleam/option.{type Option}

pub type Bookmark {
  Bookmark(
    id: Int,
    title: String,
    url: String,
    tags: List(String),
    notes: Option(String),
  )
}

pub type CreateBookmarkRequest {
  CreateBookmarkRequest(
    title: String,
    url: String,
    tags: List(String),
    notes: Option(String),
  )
}

pub type AppError {
  NotFound(id: Int)
  ValidationFailed(message: String)
  DatabaseError(message: String)
}

pub type Context {
  Context(
    db: DatabaseConnection,
    secret_key: String,
  )
}

JSON Encoding and Decoding

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// src/bookmarks_api/json_codecs.gleam
import gleam/json
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/option
import bookmarks_api/types.{type Bookmark, type CreateBookmarkRequest}

pub fn encode_bookmark(bookmark: Bookmark) -> json.Json {
  json.object([
    #("id", json.int(bookmark.id)),
    #("title", json.string(bookmark.title)),
    #("url", json.string(bookmark.url)),
    #("tags", json.array(bookmark.tags, json.string)),
    #("notes", case bookmark.notes {
      option.Some(n) -> json.string(n)
      option.None -> json.null()
    }),
  ])
}

pub fn encode_error(error: types.AppError) -> json.Json {
  case error {
    types.NotFound(id) ->
      json.object([
        #("error", json.string("not_found")),
        #("message", json.string("Bookmark " <> int.to_string(id) <> " not found")),
      ])
    types.ValidationFailed(msg) ->
      json.object([
        #("error", json.string("validation_failed")),
        #("message", json.string(msg)),
      ])
    types.DatabaseError(msg) ->
      json.object([
        #("error", json.string("database_error")),
        #("message", json.string(msg)),
      ])
  }
}

// Decoder for incoming JSON
pub fn decode_create_request(
  json: Dynamic,
) -> Result(CreateBookmarkRequest, decode.DecodeError) {
  let decoder =
    decode.into({
      use title <- decode.field("title", decode.string)
      use url   <- decode.field("url", decode.string)
      use tags  <- decode.field("tags", decode.list(decode.string))
      use notes <- decode.optional_field("notes", decode.string)
      CreateBookmarkRequest(title:, url:, tags:, notes:)
    })

  decode.run(json, decoder)
}

Routing and Request Handling

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// src/bookmarks_api/router.gleam
import wisp.{type Request, type Response}
import gleam/http.{Delete, Get, Post}
import gleam/json
import gleam/string_tree
import bookmarks_api/types.{type Context}
import bookmarks_api/json_codecs
import bookmarks_api/bookmarks

pub fn handle_request(req: Request, ctx: Context) -> Response {
  // Apply global middleware
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use <- wisp.handle_head(req)

  // Route on path segments
  case wisp.path_segments(req) {
    ["api", "bookmarks"] -> handle_bookmarks_collection(req, ctx)
    ["api", "bookmarks", id] -> handle_bookmark(req, ctx, id)
    _ -> wisp.not_found()
  }
}

fn handle_bookmarks_collection(req: Request, ctx: Context) -> Response {
  case req.method {
    Get  -> list_bookmarks(req, ctx)
    Post -> create_bookmark(req, ctx)
    _    -> wisp.method_not_allowed([Get, Post])
  }
}

fn handle_bookmark(req: Request, ctx: Context, id_str: String) -> Response {
  case req.method {
    Get    -> get_bookmark(req, ctx, id_str)
    Delete -> delete_bookmark(req, ctx, id_str)
    _      -> wisp.method_not_allowed([Get, Delete])
  }
}

fn list_bookmarks(_req: Request, ctx: Context) -> Response {
  case bookmarks.list_all(ctx.db) {
    Ok(items) -> {
      let body =
        json.array(items, json_codecs.encode_bookmark)
        |> json.to_string_tree

      wisp.ok()
      |> wisp.json_body(body)
    }
    Error(err) ->
      wisp.internal_server_error()
      |> wisp.json_body(json_codecs.encode_error(err) |> json.to_string_tree)
  }
}

fn create_bookmark(req: Request, ctx: Context) -> Response {
  // use flattens the nested callback structure
  use json_body <- wisp.require_json(req)

  case json_codecs.decode_create_request(json_body) {
    Error(_) ->
      wisp.unprocessable_content()
      |> wisp.string_body("Invalid request body")

    Ok(create_req) ->
      case validate_create_request(create_req) {
        Error(msg) ->
          wisp.bad_request()
          |> wisp.json_body(
            json_codecs.encode_error(types.ValidationFailed(msg))
            |> json.to_string_tree,
          )

        Ok(_) ->
          case bookmarks.create(ctx.db, create_req) {
            Ok(bookmark) ->
              wisp.created()
              |> wisp.json_body(
                json_codecs.encode_bookmark(bookmark) |> json.to_string_tree,
              )

            Error(err) ->
              wisp.internal_server_error()
              |> wisp.json_body(
                json_codecs.encode_error(err) |> json.to_string_tree,
              )
          }
      }
  }
}

fn get_bookmark(_req: Request, ctx: Context, id_str: String) -> Response {
  use <- result_response(int.parse(id_str))

  // This is just to show the pattern — int.parse returns Result
  // In a real handler you'd do this properly
  let id = // parsed id
  todo
}

// More idiomatic version using use throughout
fn get_bookmark_v2(req: Request, ctx: Context, id_str: String) -> Response {
  use id <- require_int_param(id_str)

  case bookmarks.get_by_id(ctx.db, id) {
    Ok(bookmark) ->
      wisp.ok()
      |> wisp.json_body(
        json_codecs.encode_bookmark(bookmark) |> json.to_string_tree,
      )
    Error(types.NotFound(_)) ->
      wisp.not_found()
    Error(err) ->
      wisp.internal_server_error()
      |> wisp.json_body(json_codecs.encode_error(err) |> json.to_string_tree)
  }
}

// Helper middleware: parse an integer parameter or return 400
fn require_int_param(
  param: String,
  next: fn(Int) -> Response,
) -> Response {
  case int.parse(param) {
    Ok(id) -> next(id)
    Error(_) ->
      wisp.bad_request()
      |> wisp.string_body("Invalid ID: must be an integer")
  }
}

fn validate_create_request(req: types.CreateBookmarkRequest) -> Result(Nil, String) {
  use <- result.guard(string.is_empty(req.title), Error("Title is required"))
  use <- result.guard(string.is_empty(req.url),   Error("URL is required"))
  use <- result.guard(
    !string.starts_with(req.url, "http"),
    Error("URL must start with http:// or https://"),
  )
  Ok(Nil)
}

Application Entry Point

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/bookmarks_api.gleam
import gleam/erlang/process
import gleam/result
import mist
import wisp
import bookmarks_api/router
import bookmarks_api/types
import bookmarks_api/database

pub fn main() {
  wisp.configure_logger()

  let secret_key = get_secret_key()

  let assert Ok(db) = database.connect("bookmarks.db")

  let ctx = types.Context(db: db, secret_key: secret_key)

  let handler = fn(req) { router.handle_request(req, ctx) }

  let assert Ok(_) =
    wisp.mist_handler(handler, secret_key)
    |> mist.new
    |> mist.port(8080)
    |> mist.start_http

  process.sleep_forever()
}

fn get_secret_key() -> String {
  // In production, read from environment variable
  // For now, generate a random key
  wisp.random_string(64)
}

Testing Handlers

Wisp includes wisp/simulate for testing handlers without starting a real server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// test/bookmarks_api_test.gleam
import gleeunit
import gleeunit/should
import wisp/simulate
import gleam/http
import bookmarks_api/router
import bookmarks_api/types

pub fn main() {
  gleeunit.main()
}

fn test_context() -> types.Context {
  types.Context(
    db: database.connect_test_db(),
    secret_key: "test-secret-key-for-testing-only-64-chars-long-padding-here",
  )
}

pub fn list_bookmarks_returns_200_test() {
  let req =
    simulate.get("/api/bookmarks", [])

  let response = router.handle_request(req, test_context())

  response.status
  |> should.equal(200)
}

pub fn create_bookmark_with_valid_data_test() {
  let body = "{\"title\":\"Gleam Docs\",\"url\":\"https://gleam.run\",\"tags\":[]}"

  let req =
    simulate.post("/api/bookmarks", [#("content-type", "application/json")], body)

  let response = router.handle_request(req, test_context())

  response.status
  |> should.equal(201)
}

pub fn create_bookmark_with_missing_title_test() {
  let body = "{\"url\":\"https://gleam.run\",\"tags\":[]}"

  let req =
    simulate.post("/api/bookmarks", [#("content-type", "application/json")], body)

  let response = router.handle_request(req, test_context())

  response.status
  |> should.equal(400)
}

An Honest Assessment

Gleam 1.0 is a real milestone. The language is genuinely good. But honest assessment requires looking at where it stands today versus the alternatives, not just where it is headed.

Where Gleam Genuinely Shines

Greenfield BEAM services where type safety matters most. If you are starting a new service and your team values type safety, Gleam is a compelling choice. You get the BEAM’s runtime characteristics — the scalability, the fault tolerance, the actor model — and you get a compiler that will not let you ship a service that crashes because someone passed a None where a value was expected. This combination does not exist anywhere else.

Small-to-medium teams building long-lived systems. The type system pays dividends over time. A codebase you have to maintain for two years benefits enormously from exhaustive pattern matching — add a new variant to your order status type and the compiler tells you every handler that needs updating. In Elixir, that is a grep and prayer exercise.

Full-stack teams who want to share types between backend and frontend. The JavaScript compilation target is genuinely useful here. Define your API contract types in Gleam, compile them to BEAM for your backend and JavaScript for your frontend. Type mismatches become compile errors rather than runtime failures that only manifest when a user hits a particular edge case.

Teams coming from typed functional languages (Elm, Haskell, OCaml) who want production systems. Gleam is a practical language for building real services. If you love Elm’s type system but want to write a backend, Gleam on the BEAM is the closest thing to Elm on the server.

Gradual adoption in existing Elixir codebases. Because Gleam and Elixir are both BEAM languages, you can introduce Gleam modules into an existing Elixir application. The interop is not seamless (dynamic typing at the boundary is unavoidable) but it is workable. Teams have successfully written new modules in Gleam while the rest of the application stays in Elixir.

Where Elixir/Erlang Is Still Better

Ecosystem maturity. Elixir’s ecosystem is dramatically larger. Phoenix alone has years of production hardening, LiveView, Channels, a mature router, PubSub, and a vast body of documentation, blog posts, and community knowledge. Ecto is a battle-tested data layer. The Hex ecosystem for Elixir is an order of magnitude richer than what Gleam has today.

Phoenix LiveView specifically. LiveView is genuinely remarkable — real-time server-rendered UIs without JavaScript, with years of production deployments behind it. There is no Gleam equivalent that comes close. If LiveView is what you need, use Elixir.

Operator familiarity. Most engineers in the BEAM ecosystem know Elixir. Hiring is easier, stackoverflow answers exist, tutorials exist. Gleam’s community is small, though friendly and growing.

Pattern matching on function heads. Elixir lets you define multiple function clauses matching different patterns:

1
2
3
4
# Elixir — multiple function heads
def handle(%{method: :get, path: "/health"} = req), do: health_check(req)
def handle(%{method: :post, path: "/api/users"} = req), do: create_user(req)
def handle(req), do: not_found(req)

Gleam requires a single function with a case body. This is not strictly worse — case expressions are more explicit — but Elixir’s approach can be more concise for certain patterns.

Dialyzer/Gradient for gradual typing. If you want type safety in an existing Elixir codebase, tools like Dialyzer (with type specs), Gradient, and the experimental Elixir type system (the Erlang typechecker integration announced for Elixir 1.17+) can get you partway there without a full rewrite.

The 1.0 Milestone and What It Means

The 1.0 release in March 2024 meant: stability guarantee, semantic versioning commitment, no more breaking changes to the language design without significant justification. This matters.

Before 1.0, adopting Gleam in production was genuinely risky — the language could change. Post-1.0, that risk is substantially reduced. The API stability means your 1.0-era Gleam code will still compile in Gleam 1.15 (which it does). The language team has been disciplined about this.

The post-1.0 velocity has been impressive: monthly releases, each containing real improvements to developer experience. The language server has gotten significantly better. The JavaScript target performance improved by 30% in v1.11. Code actions, global rename, fault-tolerant compilation — these are professional tooling improvements.

The Honest Bottom Line

Use Gleam if:

  • You are starting a new BEAM service and type safety is a priority
  • Your team has functional programming experience and will benefit from the type system
  • You want to share types between backend (BEAM) and frontend (JS) in a single codebase
  • You are willing to invest time in a smaller ecosystem in exchange for a compiler that has your back

Use Elixir if:

  • You need Phoenix LiveView
  • You need a mature, well-staffed ecosystem with production answers to common problems
  • Your team knows Elixir and hiring matters
  • You are building on top of existing Elixir/Phoenix infrastructure

Use both if:

  • You are introducing type safety gradually into an existing Elixir codebase
  • Different services have different requirements (new services in Gleam, existing services staying Elixir)
  • You want to evaluate Gleam on a real project before committing

The comparison with Elixir is the relevant one because the target audiences overlap significantly. But it is worth noting that Gleam is not competing with Elixir as much as it is occupying a different point in the type-safety/ecosystem tradeoff. Elixir chose a dynamic type system with optional gradual typing; Gleam chose a static type system from day one. Neither choice is wrong — they reflect different values and different use cases.


Getting Started

The full tour is at tour.gleam.run — interactive, in-browser, covers everything. The cheatsheets at gleam.run/cheatsheets are excellent if you are coming from Elixir, Erlang, Elm, or Python.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install (via ASDF or direct binary)
asdf plugin add gleam
asdf install gleam latest
asdf global gleam latest

# Or via the official installer
curl -fsSL https://gleam.run/install.sh | sh

# Verify
gleam --version

# Create your first project
gleam new hello_beam
cd hello_beam
gleam run

The community is on Discord and the Gleam forum. Both are genuinely welcoming — the core team participates actively and the signal-to-noise ratio is high.


Conclusion

Gleam is the most serious attempt to date to bring ML-family type safety to the BEAM, and as of v1.15 it succeeds. The type system is sound and practical. The use expression is a genuine innovation that solves a real problem. The tooling is excellent. The JavaScript compilation target is well-executed. The Erlang interop story is workable.

The ecosystem is the honest limitation. Gleam is not a drop-in replacement for Elixir in 2026. If you need Phoenix, Ecto, LiveView, or any of the dozens of mature Elixir libraries built over the last decade, you are not getting that from Gleam today. The community is building fast, but “fast” is relative to a small starting point.

What Gleam offers is a language that takes type safety as seriously as Elm or OCaml, running on a runtime that takes fault tolerance as seriously as Erlang, with tooling that takes developer experience as seriously as Go. That combination is novel. For the right use cases — and there are real, important use cases — it is the best tool for the job.

The “if it compiles it mostly works” experience is real. Write a Gleam service, add a new field to a type, watch the compiler enumerate every place that needs updating. Refactor an error type and watch the type system guide you through every call site. It is the kind of feedback loop that makes codebases maintainable over years, not just months.

Gleam 1.0 arrived in 2024. The trajectory since then has been steady and disciplined. It is worth your attention.

Comments