towerops/docs/references/gleam-best-practices.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

39 KiB

Gleam Language Reference & Best Practices

Practical reference for building production Gleam applications on the BEAM. Covers language fundamentals, OTP, web development, and real-world patterns.


Table of Contents

  1. Language Fundamentals
  2. OTP from Gleam
  3. Web Development
  4. Package Ecosystem
  5. Interop (Erlang, Elixir, JavaScript)
  6. Testing
  7. Project Structure
  8. Real-World Patterns

1. Language Fundamentals

The Type System

Gleam is statically typed with full type inference. No null, no exceptions, no implicit conversions.

Result Type

The core error handling primitive. Every fallible operation returns Result(value, error).

import gleam/result
import gleam/int

pub fn parse_age(input: String) -> Result(Int, String) {
  case int.parse(input) {
    Ok(age) if age >= 0 && age <= 150 -> Ok(age)
    Ok(_) -> Error("Age out of range")
    Error(_) -> Error("Not a number")
  }
}

pub fn main() {
  // Chain results with result.try (like flatMap/bind)
  let output =
    "25"
    |> parse_age
    |> result.map(fn(age) { age + 1 })

  // Pattern match on results
  case output {
    Ok(age) -> echo age
    Error(msg) -> echo msg
  }
}

Option (gleam/option)

Gleam has no null. Optional values use Option(a) which is Some(a) | None.

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

pub type User {
  User(name: String, email: Option(String))
}

pub fn display_email(user: User) -> String {
  case user.email {
    Some(email) -> email
    None -> "No email provided"
  }
}

// option module has helpers
pub fn get_email_or_default(user: User) -> String {
  user.email
  |> option.unwrap("no-reply@example.com")
}

Custom Types (Algebraic Data Types)

Custom types are the backbone of Gleam data modeling. They're tagged unions (sum types) with named fields (product types).

// Simple enum
pub type Color {
  Red
  Green
  Blue
}

// Record with fields
pub type User {
  User(id: Int, name: String, role: Role)
}

// Sum type (tagged union)
pub type Role {
  Admin
  Moderator(permissions: List(String))
  Member
}

// Generic types
pub type Validated(a) {
  Valid(value: a)
  Invalid(errors: List(String))
}

// Opaque types - hide internals from other modules
pub opaque type Email {
  Email(String)
}

pub fn new_email(value: String) -> Result(Email, String) {
  case value {
    _ if value == "" -> Error("Email cannot be empty")
    _ -> Ok(Email(value))
  }
}

pub fn to_string(email: Email) -> String {
  let Email(value) = email
  value
}

Record Updates

pub type Config {
  Config(host: String, port: Int, debug: Bool)
}

pub fn enable_debug(config: Config) -> Config {
  Config(..config, debug: True)
}

Pattern Matching

Pattern matching is pervasive in Gleam — it's the primary flow control mechanism.

import gleam/int
import gleam/list

// Match on custom types
pub fn describe_role(role: Role) -> String {
  case role {
    Admin -> "Administrator"
    Moderator(perms) -> "Mod with " <> int.to_string(list.length(perms)) <> " permissions"
    Member -> "Regular member"
  }
}

// Multiple subjects
pub fn classify(x: Int, y: Int) -> String {
  case x, y {
    0, 0 -> "Origin"
    0, _ -> "Y axis"
    _, 0 -> "X axis"
    _, _ -> "Somewhere else"
  }
}

// Guards
pub fn categorize_age(age: Int) -> String {
  case age {
    a if a < 0 -> "Invalid"
    a if a < 13 -> "Child"
    a if a < 18 -> "Teenager"
    a if a < 65 -> "Adult"
    _ -> "Senior"
  }
}

// String prefix matching
pub fn parse_command(input: String) -> Result(String, String) {
  case input {
    "GET " <> path -> Ok(path)
    "POST " <> path -> Ok(path)
    _ -> Error("Unknown command")
  }
}

// List patterns
pub fn first_two(items: List(a)) -> String {
  case items {
    [] -> "empty"
    [_] -> "one item"
    [_, _, ..] -> "two or more"
  }
}

// Nested patterns
pub fn get_admin_name(user: User) -> Result(String, Nil) {
  case user {
    User(name:, role: Admin, ..) -> Ok(name)
    _ -> Error(Nil)
  }
}

// As patterns (bind a name to the whole matched value)
pub fn check(user: User) -> User {
  case user {
    User(role: Admin, ..) as admin -> {
      echo "Admin found"
      admin
    }
    other -> other
  }
}

Use Expressions

use is syntactic sugar for callback functions. It converts the rest of the function body into an anonymous function passed as the last argument.

import gleam/result
import gleam/list

// WITHOUT use - nested callbacks
pub fn without_use(input: String) -> Result(String, String) {
  result.try(parse_name(input), fn(name) {
    result.try(validate_name(name), fn(valid_name) {
      result.map(format_greeting(valid_name), fn(greeting) {
        greeting
      })
    })
  })
}

// WITH use - flat and readable
pub fn with_use(input: String) -> Result(String, String) {
  use name <- result.try(parse_name(input))
  use valid_name <- result.try(validate_name(name))
  use greeting <- result.map(format_greeting(valid_name))
  greeting
}

// use with list.map
pub fn double_all(numbers: List(Int)) -> List(Int) {
  use n <- list.map(numbers)
  n * 2
}

// use with list.filter
pub fn only_positive(numbers: List(Int)) -> List(Int) {
  use n <- list.filter(numbers)
  n > 0
}

// use for middleware (very common in web handlers)
pub fn handle(req: Request) -> Response {
  use <- wisp.log_request(req)
  use <- wisp.serve_static(req, under: "/static", from: "/public")
  use req <- middleware(req)
  route(req)
}

How use works mechanically:

// This:
use x <- some_function(arg1, arg2)
do_something(x)

// Desugars to:
some_function(arg1, arg2, fn(x) {
  do_something(x)
})

Pipelines

The |> operator passes the left-hand value as the first argument to the right-hand function.

import gleam/string
import gleam/list
import gleam/int

pub fn format_names(names: List(String)) -> String {
  names
  |> list.map(string.trim)
  |> list.filter(fn(s) { s != "" })
  |> list.sort(string.compare)
  |> list.map(string.capitalise)
  |> string.join(", ")
}

// Pipe to a different argument position with function capture
pub fn example() {
  "world"
  |> string.append("hello ", _)  // Becomes string.append("hello ", "world")
}

// Debug in the middle of a pipeline
pub fn debug_pipeline(x: Int) -> Int {
  x
  |> int.multiply(2)
  |> echo  // prints the intermediate value, passes it through
  |> int.add(1)
}

Labelled Arguments

pub fn create_user(
  name name: String,
  email email: String,
  role role: Role,
) -> User {
  User(id: 0, name:, role:)
}

// Call with labels (order doesn't matter for labelled args)
pub fn example() {
  create_user(role: Admin, name: "Alice", email: "alice@example.com")
}

// Shorthand: when variable name matches label
pub fn example2() {
  let name = "Bob"
  let email = "bob@example.com"
  let role = Member
  create_user(name:, email:, role:)
}

Generics

// Generic function
pub fn first(pair: #(a, b)) -> a {
  pair.0
}

// Generic custom type
pub type Stack(a) {
  Stack(items: List(a))
}

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)))
  }
}

// Constrained generics don't exist in Gleam —
// use higher-order functions instead:
pub fn map_pair(pair: #(a, a), f: fn(a) -> b) -> #(b, b) {
  #(f(pair.0), f(pair.1))
}

2. OTP from Gleam

Gleam runs on the BEAM and has first-class OTP support via gleam_erlang and gleam_otp.

gleam add gleam_otp gleam_erlang

Processes and Subjects

A Subject(msg) is a type-safe process mailbox reference. It's Gleam's answer to untyped Erlang PIDs.

import gleam/erlang/process.{type Subject}

pub fn spawn_logger() -> Subject(String) {
  // process.new_subject() creates a subject for receiving messages
  let subject = process.new_subject()

  // Spawn a process
  process.start(linked: True, running: fn() {
    logger_loop(subject)
  })

  subject
}

fn logger_loop(subject: Subject(String)) -> Nil {
  // Receive a message (blocks until one arrives)
  let msg = process.receive(subject, within: 5000)
  case msg {
    Ok(text) -> {
      echo text
      logger_loop(subject)
    }
    Error(Nil) -> {
      echo "Logger timed out, stopping"
      Nil
    }
  }
}

pub fn main() {
  let logger = spawn_logger()
  process.send(logger, "Hello from main!")
  process.send(logger, "Another message")
  process.sleep(100)
}

Actors (gleam_otp)

Actors are the recommended abstraction — they handle OTP system messages automatically.

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

// Define your message type
pub type CounterMsg {
  Increment(by: Int)
  Decrement(by: Int)
  GetCount(reply_to: Subject(Int))
  Reset
}

// State is just a plain type
pub type CounterState {
  CounterState(count: Int, name: String)
}

// Start the actor
pub fn start(name: String) -> Result(Subject(CounterMsg), actor.StartError) {
  let init_state = CounterState(count: 0, name:)

  actor.new(init_state)
  |> actor.on_message(handle_message)
  |> actor.start
}

// Handle messages — return actor.Next to continue or actor.Stop to shut down
fn handle_message(
  state: CounterState,
  message: CounterMsg,
) -> actor.Next(CounterState, CounterMsg) {
  case message {
    Increment(by:) -> {
      let new_state = CounterState(..state, count: state.count + by)
      actor.continue(new_state)
    }

    Decrement(by:) -> {
      let new_state = CounterState(..state, count: state.count - by)
      actor.continue(new_state)
    }

    GetCount(reply_to:) -> {
      actor.send(reply_to, state.count)
      actor.continue(state)
    }

    Reset -> {
      actor.continue(CounterState(..state, count: 0))
    }
  }
}

// Client API — hide the message protocol behind functions
pub fn increment(counter: Subject(CounterMsg), by amount: Int) -> Nil {
  actor.send(counter, Increment(by: amount))
}

pub fn get_count(counter: Subject(CounterMsg)) -> Int {
  // actor.call sends a message and waits for a reply
  actor.call(counter, GetCount, within: 1000)
}

pub fn main() {
  let assert Ok(counter) = start("my_counter")

  increment(counter, by: 5)
  increment(counter, by: 3)
  actor.send(counter, Decrement(by: 2))

  let count = get_count(counter)
  echo count  // 6
}

Supervisors

import gleam/otp/static_supervisor

pub fn start_app() {
  static_supervisor.new(static_supervisor.OneForOne)
  |> static_supervisor.add(static_supervisor.worker_child(
    id: "counter_1",
    run: fn(_) { start("counter_1") },
  ))
  |> static_supervisor.add(static_supervisor.worker_child(
    id: "counter_2",
    run: fn(_) { start("counter_2") },
  ))
  |> static_supervisor.start
}

Selectors (Listening to Multiple Subjects)

import gleam/erlang/process.{type Selector, type Subject}

pub type Event {
  UserMessage(String)
  SystemAlert(String)
  Tick
}

pub fn listen(
  user_sub: Subject(String),
  system_sub: Subject(String),
  tick_sub: Subject(Nil),
) -> Event {
  // Build a selector that maps different subjects to a unified Event type
  let selector =
    process.new_selector()
    |> process.selecting(user_sub, UserMessage)
    |> process.selecting(system_sub, SystemAlert)
    |> process.selecting(tick_sub, fn(_) { Tick })

  // Wait for any of them
  let assert Ok(event) = process.select(selector, within: 5000)
  event
}

3. Web Development

Wisp Framework

Wisp is the standard web framework for Gleam. It wraps gleam_http types and provides middleware.

gleam add wisp mist gleam_http gleam_erlang

Basic App Structure

// src/app.gleam — entry point
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
import app/router
import app/web.{type Context, Context}

pub fn main() {
  wisp.configure_logger()

  let secret_key_base = wisp.random_string(64)
  let ctx = Context(
    secret_key_base:,
    db: Nil,  // your DB connection goes here
  )

  let assert Ok(_) =
    wisp_mist.handler(router.handle_request(_, ctx), secret_key_base)
    |> mist.new
    |> mist.port(8000)
    |> mist.start_http

  process.sleep_forever()
}

Context Type

// src/app/web.gleam
pub type Context {
  Context(secret_key_base: String, db: connection)
}

Routing

// src/app/router.gleam
import wisp.{type Request, type Response}
import app/web.{type Context}
import app/handlers/user_handler
import app/handlers/health_handler

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

  // Route based on method + path segments
  case wisp.path_segments(req) {
    // GET /
    [] -> wisp.ok() |> wisp.string_body("Welcome!")

    // GET /health
    ["health"] -> health_handler.index(req)

    // /users/...
    ["users", ..rest] -> user_routes(req, ctx, rest)

    // 404
    _ -> wisp.not_found()
  }
}

fn user_routes(req: Request, ctx: Context, path: List(String)) -> Response {
  case req.method, path {
    // GET /users
    http.Get, [] -> user_handler.list(req, ctx)

    // POST /users
    http.Post, [] -> user_handler.create(req, ctx)

    // GET /users/:id
    http.Get, [id] -> user_handler.show(req, ctx, id)

    // PUT /users/:id
    http.Put, [id] -> user_handler.update(req, ctx, id)

    // DELETE /users/:id
    http.Delete, [id] -> user_handler.delete(req, ctx, id)

    // Method not allowed
    _, _ -> wisp.method_not_allowed([http.Get, http.Post, http.Put, http.Delete])
  }
}

fn middleware(req: Request, ctx: Context, handler: fn(Request) -> Response) -> Response {
  let req = wisp.method_override(req)
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use req <- wisp.handle_head(req)
  use <- wisp.serve_static(req, under: "/static", from: static_directory())

  handler(req)
}

fn static_directory() -> String {
  let assert Ok(priv) = wisp.priv_directory("app")
  priv <> "/static"
}

Request Handling

// src/app/handlers/user_handler.gleam
import gleam/http
import gleam/json
import gleam/dynamic/decode
import wisp.{type Request, type Response}
import app/web.{type Context}

pub fn create(req: Request, ctx: Context) -> Response {
  // Read JSON body
  use json_body <- wisp.require_json(req)

  // Decode the JSON
  let decoder =
    decode.into({
      use name <- decode.parameter
      use email <- decode.parameter
      #(name, email)
    })
    |> decode.field("name", decode.string)
    |> decode.field("email", decode.string)

  case decode.run(json_body, decoder) {
    Ok(#(name, email)) -> {
      // Do something with the data...
      let response_json =
        json.object([
          #("name", json.string(name)),
          #("email", json.string(email)),
          #("id", json.int(1)),
        ])
        |> json.to_string_tree

      wisp.json_response(response_json, 201)
    }
    Error(_) -> wisp.unprocessable_entity()
  }
}

pub fn list(_req: Request, _ctx: Context) -> Response {
  let body =
    json.array([
      json.object([#("id", json.int(1)), #("name", json.string("Alice"))]),
      json.object([#("id", json.int(2)), #("name", json.string("Bob"))]),
    ], of: fn(x) { x })
    |> json.to_string_tree

  wisp.json_response(body, 200)
}

Lustre (Frontend Framework)

Lustre is Gleam's Elm-inspired frontend framework. Model-Update-View architecture.

gleam add lustre
gleam add --dev lustre_dev_tools

Counter App

import gleam/int
import lustre
import lustre/attribute
import lustre/element.{text}
import lustre/element/html.{button, div, h1, p}
import lustre/event

// MODEL
pub type Model {
  Model(count: Int, name: String)
}

fn init(_flags) -> Model {
  Model(count: 0, name: "Counter")
}

// UPDATE
pub type Msg {
  Increment
  Decrement
  Reset
  SetName(String)
}

fn update(model: Model, msg: Msg) -> Model {
  case msg {
    Increment -> Model(..model, count: model.count + 1)
    Decrement -> Model(..model, count: model.count - 1)
    Reset -> Model(..model, count: 0)
    SetName(name) -> Model(..model, name:)
  }
}

// VIEW
fn view(model: Model) -> element.Element(Msg) {
  div([], [
    h1([], [text(model.name)]),
    p([], [text("Count: " <> int.to_string(model.count))]),
    button([event.on_click(Increment)], [text("+")]),
    button([event.on_click(Decrement)], [text("-")]),
    button([event.on_click(Reset)], [text("Reset")]),
    html.input([
      attribute.value(model.name),
      event.on_input(SetName),
      attribute.placeholder("Counter name"),
    ]),
  ])
}

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

Lustre with Effects

For apps that need side effects (HTTP requests, timers, etc.), use lustre.application instead of lustre.simple.

import lustre
import lustre/effect.{type Effect}

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)
  Nil
}

fn init(_flags) -> #(Model, Effect(Msg)) {
  #(Model(loading: True, data: None), fetch_data())
}

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    DataLoaded(data) -> #(Model(loading: False, data: Some(data)), effect.none())
    FetchFailed -> #(Model(loading: False, data: None), effect.none())
    Refresh -> #(Model(..model, loading: True), fetch_data())
  }
}

fn fetch_data() -> Effect(Msg) {
  // Use lustre_http or similar packages for HTTP effects
  effect.none()  // placeholder
}

Lustre Server Components

Lustre components can run on the server and push updates to connected clients, similar to Phoenix LiveView.

import lustre
import lustre/server_component

// Create a server component
pub fn app() {
  lustre.component(init, update, view, on_attribute_change())
}

fn on_attribute_change() -> Dict(String, Decoder(Msg)) {
  dict.new()
}

Mist HTTP Server

Mist is the HTTP server that Wisp runs on top of. You can use it directly for lower-level control.

import gleam/bytes_tree
import gleam/http/response
import gleam/erlang/process
import mist

pub fn main() {
  let assert Ok(_) =
    fn(_req) {
      response.new(200)
      |> response.set_body(mist.Bytes(
        bytes_tree.from_string("Hello from Mist!"),
      ))
    }
    |> mist.new
    |> mist.port(3000)
    |> mist.start_http

  process.sleep_forever()
}

4. Package Ecosystem

Key Packages

Package Purpose Install
gleam_stdlib Standard library (lists, strings, result, option, etc.) Included by default
gleam_json JSON encoding/decoding gleam add gleam_json
gleam_http HTTP types (Request, Response, Method) gleam add gleam_http
gleam_crypto Hashing, HMAC, secure random gleam add gleam_crypto
gleam_erlang Erlang interop, process, atoms gleam add gleam_erlang
gleam_otp OTP actors, supervisors gleam add gleam_otp
wisp Web framework gleam add wisp
mist HTTP server gleam add mist
lustre Frontend framework gleam add lustre
sqlight SQLite bindings gleam add sqlight
gleam_pgo PostgreSQL client (PGO) gleam add gleam_pgo
cake SQL query builder gleam add cake
envoy Environment variables gleam add envoy
argv CLI argument parsing gleam add argv
tom TOML parser gleam add tom
simplifile File system operations gleam add simplifile
gleeunit Test runner gleam add --dev gleeunit

gleam_json

import gleam/json
import gleam/dynamic/decode

// Encoding
pub fn encode_user(user: User) -> String {
  json.object([
    #("id", json.int(user.id)),
    #("name", json.string(user.name)),
    #("role", encode_role(user.role)),
  ])
  |> json.to_string
}

fn encode_role(role: Role) -> json.Json {
  case role {
    Admin -> json.string("admin")
    Moderator(perms) -> json.object([
      #("type", json.string("moderator")),
      #("permissions", json.array(perms, json.string)),
    ])
    Member -> json.string("member")
  }
}

// Decoding
pub fn decode_user(data: String) -> Result(User, json.DecodeError) {
  let user_decoder =
    decode.into({
      use id <- decode.parameter
      use name <- decode.parameter
      use role <- decode.parameter
      User(id:, name:, role:)
    })
    |> decode.field("id", decode.int)
    |> decode.field("name", decode.string)
    |> decode.field("role", role_decoder())

  json.parse(data, user_decoder)
}

fn role_decoder() -> decode.Decoder(Role) {
  decode.one_of(decode.string |> decode.then(fn(s) {
    case s {
      "admin" -> decode.into(Admin)
      "member" -> decode.into(Member)
      _ -> decode.fail("Role")
    }
  }), [
    // Or decode the object form
    decode.into({
      use perms <- decode.parameter
      Moderator(permissions: perms)
    })
    |> decode.field("permissions", decode.list(decode.string)),
  ])
}

sqlight (SQLite)

import sqlight

pub fn main() {
  use conn <- sqlight.with_connection("mydb.sqlite")

  // Create table
  let assert Ok(_) =
    sqlight.exec(
      "CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE
      )",
      conn,
    )

  // Insert
  let assert Ok(_) =
    sqlight.query(
      "INSERT INTO users (name, email) VALUES (?, ?)",
      conn,
      [sqlight.text("Alice"), sqlight.text("alice@example.com")],
      dynamic.dynamic,
    )

  // Query
  let assert Ok(rows) =
    sqlight.query(
      "SELECT id, name, email FROM users WHERE name = ?",
      conn,
      [sqlight.text("Alice")],
      decode.into({
        use id <- decode.parameter
        use name <- decode.parameter
        use email <- decode.parameter
        #(id, name, email)
      })
      |> decode.field(0, decode.int)
      |> decode.field(1, decode.string)
      |> decode.field(2, decode.string),
    )

  echo rows
}

Publishing to Hex

# gleam.toml
name = "my_package"
version = "1.0.0"
description = "A useful Gleam package"
licences = ["Apache-2.0"]

[repository]
type = "github"
user = "myuser"
repo = "my_package"
# Build and publish
gleam publish

# Retire a version (if you published something broken)
gleam hex retire my_package 1.0.0 security "Use 1.0.1 instead"

5. Interop

Calling Erlang from Gleam (FFI)

Create an Erlang file alongside your Gleam module:

// src/app/crypto_utils.gleam

// Declare external Erlang functions
@external(erlang, "crypto", "hash")
fn erlang_hash(algorithm: atom, data: BitArray) -> BitArray

// Wrap it in a Gleam-friendly API
import gleam/erlang/atom

pub fn sha256(data: BitArray) -> BitArray {
  let assert Ok(algo) = atom.from_string("sha256")
  erlang_hash(algo, data)
}

For custom Erlang code, create a .erl file in src/:

%% src/my_ffi.erl
-module(my_ffi).
-export([system_time/0, format_timestamp/1]).

system_time() ->
    erlang:system_time(millisecond).

format_timestamp(Ms) ->
    calendar:system_time_to_rfc3339(Ms div 1000, [{unit, second}]).
// src/app/time.gleam

@external(erlang, "my_ffi", "system_time")
pub fn system_time_ms() -> Int

@external(erlang, "my_ffi", "format_timestamp")
pub fn format_timestamp(ms: Int) -> String

Calling Elixir from Gleam

Elixir modules are just Erlang modules with an Elixir. prefix:

// Call Elixir's Jason library
@external(erlang, "Elixir.Jason", "encode!")
fn jason_encode(term: dynamic.Dynamic) -> String

// Call Elixir's Enum module
@external(erlang, "Elixir.Enum", "shuffle")
pub fn shuffle(list: List(a)) -> List(a)

Important: To use Elixir dependencies, add them to your gleam.toml:

[dependencies]
jason = ">= 1.4.0 and < 2.0.0"  # Elixir/Erlang hex packages work

JavaScript Target FFI

For the JavaScript target, create .mjs files:

// src/app/dom_ffi.mjs
export function get_element(id) {
  const el = document.getElementById(id);
  if (el) return new Ok(el);
  return new Error(undefined);
}

export function set_inner_html(element, html) {
  element.innerHTML = html;
  return undefined;
}
// src/app/dom.gleam

@external(javascript, "./dom_ffi.mjs", "get_element")
pub fn get_element(id: String) -> Result(Dynamic, Nil)

@external(javascript, "./dom_ffi.mjs", "set_inner_html")
pub fn set_inner_html(element: Dynamic, html: String) -> Nil

Target-Conditional Code

// Provide different implementations per target
@external(erlang, "my_ffi", "monotonic_time")
@external(javascript, "./time_ffi.mjs", "monotonic_time")
pub fn monotonic_time() -> Int

Erlang Target vs JavaScript Target

Feature Erlang (BEAM) JavaScript
Concurrency Full OTP (actors, supervisors) Single-threaded (async)
Int size Arbitrary precision 64-bit float
FFI target .erl files .mjs files
Use cases Servers, distributed systems Browser apps, serverless
OTP support Full Not available
Package compat Erlang + Elixir hex packages npm packages via FFI
Build output BEAM bytecode JavaScript modules

6. Testing

gleeunit

Gleam's standard test runner. Any public function in test/ ending in _test is run as a test.

// test/app_test.gleam
import gleeunit
import gleeunit/should
import app/user

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

pub fn parse_age_valid_test() {
  user.parse_age("25")
  |> should.be_ok
  |> should.equal(25)
}

pub fn parse_age_negative_test() {
  user.parse_age("-1")
  |> should.be_error
  |> should.equal("Age out of range")
}

pub fn parse_age_not_number_test() {
  user.parse_age("abc")
  |> should.be_error
  |> should.equal("Not a number")
}
gleam test

Custom Assertions

// test/test_helpers.gleam
import gleam/string

pub fn should_contain(haystack: String, needle: String) -> String {
  case string.contains(haystack, needle) {
    True -> haystack
    False ->
      panic as {
        "Expected \"" <> haystack <> "\" to contain \"" <> needle <> "\""
      }
  }
}

pub fn should_be_between(value: Int, low: Int, high: Int) -> Int {
  case value >= low && value <= high {
    True -> value
    False ->
      panic as "Expected value to be between bounds"
  }
}
// test/string_test.gleam
import test_helpers.{should_contain}

pub fn greeting_test() {
  build_greeting("Alice")
  |> should_contain("Alice")
  |> should_contain("Hello")
}

Testing Actors

import gleam/erlang/process

pub fn counter_increment_test() {
  let assert Ok(counter) = counter.start("test")

  counter.increment(counter, by: 5)
  counter.increment(counter, by: 3)

  counter.get_count(counter)
  |> should.equal(8)
}

Testing Wisp Handlers

import wisp/testing

pub fn health_check_test() {
  let req = testing.get("/health", [])
  let resp = router.handle_request(req, test_context())

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

pub fn create_user_test() {
  let body = "{\"name\": \"Alice\", \"email\": \"alice@test.com\"}"
  let req = testing.post_json("/users", [], body)
  let resp = router.handle_request(req, test_context())

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

fn test_context() -> Context {
  Context(secret_key_base: "test-secret", db: Nil)
}

7. Project Structure

gleam.toml Configuration

name = "my_app"
version = "1.0.0"
target = "erlang"                    # or "javascript"
description = "My Gleam application"
licences = ["Apache-2.0"]

# OTP application config (for BEAM deployment)
[erlang]
application_start_module = "my_app/application"
extra_applications = ["crypto", "ssl"]  # Erlang apps to start

[repository]
type = "github"
user = "myuser"
repo = "my_app"

[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
gleam_erlang = ">= 0.25.0 and < 2.0.0"
gleam_otp = ">= 0.10.0 and < 2.0.0"
gleam_http = ">= 3.6.0 and < 4.0.0"
gleam_json = ">= 1.0.0 and < 3.0.0"
wisp = ">= 1.0.0 and < 3.0.0"
mist = ">= 1.0.0 and < 4.0.0"

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

Module Organization

my_app/
├── gleam.toml
├── manifest.toml          # Lock file (commit this)
├── src/
│   ├── my_app.gleam       # Entry point (pub fn main)
│   ├── my_app/
│   │   ├── application.gleam  # OTP application (supervisor tree)
│   │   ├── router.gleam       # HTTP routing
│   │   ├── web.gleam          # Context type, shared web helpers
│   │   ├── internal.gleam     # Internal helpers (not public API)
│   │   ├── handlers/
│   │   │   ├── user_handler.gleam
│   │   │   └── health_handler.gleam
│   │   ├── models/
│   │   │   ├── user.gleam     # User type + encoding/decoding
│   │   │   └── session.gleam
│   │   ├── services/
│   │   │   ├── auth.gleam     # Business logic
│   │   │   └── email.gleam
│   │   └── db/
│   │       ├── repo.gleam     # Database connection + queries
│   │       └── migrations.gleam
│   └── my_ffi.erl            # Erlang FFI (if needed)
├── test/
│   ├── my_app_test.gleam
│   └── my_app/
│       ├── handlers/
│       │   └── user_handler_test.gleam
│       └── models/
│           └── user_test.gleam
└── priv/
    └── static/                # Static files served by wisp
        ├── css/
        └── js/

Gleam vs Elixir: When to Use Each

Dimension Gleam Elixir
Type safety Full static types, no runtime type errors Dynamic typing, runtime crashes possible
Ecosystem maturity 🟡 Growing rapidly, some gaps Mature, battle-tested (Phoenix, Ecto, LiveView)
OTP support 🟡 Subset (type-safe actors, supervisors) Full OTP (GenServer, GenStage, DynamicSupervisor)
Metaprogramming No macros (by design) Powerful macros
Learning curve Small language, few concepts 🟡 More features to learn
Refactoring Compiler catches everything 🟡 Tests + dialyzer help, but gaps exist
Web (backend) 🟡 Wisp is solid but young Phoenix is world-class
Web (frontend) Lustre (Elm-like, universal components) 🟡 LiveView (server-dependent)
Database 🟡 sqlight, gleam_pgo, cake (basic) Ecto (migrations, schemas, changesets)
Interop Calls Erlang & Elixir directly Calls Erlang directly
JS target Compiles to JavaScript BEAM only

Use Gleam when:

  • Type safety is a priority
  • You want compile-time guarantees
  • Building API services where Wisp's simplicity is sufficient
  • Frontend + backend in one language (JavaScript target)
  • Starting a new service that doesn't need Ecto/Phoenix ecosystem

Use Elixir when:

  • You need the full Phoenix/LiveView stack
  • Ecto's database abstractions are important
  • You need macros or DSLs
  • The library you need only exists in Elixir
  • You're extending an existing Elixir codebase

Use both:

  • Gleam and Elixir interop seamlessly on the BEAM
  • Write type-safe core logic in Gleam, use Elixir for Phoenix/Ecto
  • Gradually introduce Gleam into an Elixir project

8. Real-World Patterns

Error Handling with Result Chains

import gleam/result

pub type AppError {
  NotFound(resource: String)
  Unauthorized
  ValidationError(field: String, message: String)
  DatabaseError(detail: String)
}

// Chain operations that can fail
pub fn update_user_email(
  db: Connection,
  user_id: Int,
  new_email: String,
) -> Result(User, AppError) {
  use user <- result.try(find_user(db, user_id))
  use validated_email <- result.try(validate_email(new_email))
  use updated <- result.try(save_user(db, User(..user, email: validated_email)))
  Ok(updated)
}

// Map errors between types
pub fn find_user(db: Connection, id: Int) -> Result(User, AppError) {
  db_query(db, id)
  |> result.map_error(fn(_) { NotFound("user") })
}

// Recover from errors
pub fn get_user_or_guest(db: Connection, id: Int) -> User {
  find_user(db, id)
  |> result.unwrap(guest_user())
}

// Convert AppError to HTTP responses
pub fn error_to_response(error: AppError) -> Response {
  case error {
    NotFound(resource) ->
      wisp.not_found()
      |> wisp.string_body(resource <> " not found")
    Unauthorized ->
      wisp.response(401)
      |> wisp.string_body("Unauthorized")
    ValidationError(field, message) ->
      wisp.unprocessable_entity()
      |> wisp.string_body(field <> ": " <> message)
    DatabaseError(_) ->
      wisp.internal_server_error()
  }
}

Configuration

import envoy
import gleam/int
import gleam/result

pub type Config {
  Config(
    port: Int,
    host: String,
    database_url: String,
    secret_key: String,
    log_level: LogLevel,
  )
}

pub type LogLevel {
  Debug
  Info
  Warn
  Error
}

pub fn load() -> Result(Config, String) {
  use port <- result.try(
    envoy.get("PORT")
    |> result.then(int.parse)
    |> result.replace_error("Invalid PORT"),
  )
  use host <- result.try(
    envoy.get("HOST")
    |> result.replace_error("Missing HOST"),
  )
  use database_url <- result.try(
    envoy.get("DATABASE_URL")
    |> result.replace_error("Missing DATABASE_URL"),
  )
  use secret_key <- result.try(
    envoy.get("SECRET_KEY")
    |> result.replace_error("Missing SECRET_KEY"),
  )
  let log_level =
    envoy.get("LOG_LEVEL")
    |> result.unwrap("info")
    |> parse_log_level

  Ok(Config(port:, host:, database_url:, secret_key:, log_level:))
}

fn parse_log_level(s: String) -> LogLevel {
  case s {
    "debug" -> Debug
    "warn" -> Warn
    "error" -> Error
    _ -> Info
  }
}

Logging

import gleam/io
import gleam/erlang

// Use wisp's built-in logging (wraps Erlang's logger)
import wisp

pub fn main() {
  wisp.configure_logger()
  wisp.log_info("Application starting")
  wisp.log_warning("Something might be wrong")
  wisp.log_error("Something went wrong!")
}

// Or use Erlang's logger directly via FFI
@external(erlang, "logger", "info")
pub fn log_info(message: String) -> Nil

JSON API Design Pattern

import gleam/json
import gleam/dynamic/decode
import gleam/http
import wisp.{type Request, type Response}

// Consistent API response wrapper
pub fn json_success(data: json.Json, status: Int) -> Response {
  json.object([
    #("ok", json.bool(True)),
    #("data", data),
  ])
  |> json.to_string_tree
  |> wisp.json_response(status)
}

pub fn json_error(message: String, status: Int) -> Response {
  json.object([
    #("ok", json.bool(False)),
    #("error", json.string(message)),
  ])
  |> json.to_string_tree
  |> wisp.json_response(status)
}

// Full CRUD handler example
pub fn handle(req: Request, ctx: Context, id: String) -> Response {
  case req.method {
    http.Get -> show(ctx, id)
    http.Put -> {
      use body <- wisp.require_json(req)
      update(ctx, id, body)
    }
    http.Delete -> delete(ctx, id)
    _ -> wisp.method_not_allowed([http.Get, http.Put, http.Delete])
  }
}

fn show(ctx: Context, id: String) -> Response {
  case find_item(ctx.db, id) {
    Ok(item) -> json_success(encode_item(item), 200)
    Error(NotFound(_)) -> json_error("Not found", 404)
    Error(_) -> json_error("Internal error", 500)
  }
}

fn update(ctx: Context, id: String, body: Dynamic) -> Response {
  let decoder =
    decode.into({
      use name <- decode.parameter
      use value <- decode.parameter
      #(name, value)
    })
    |> decode.field("name", decode.string)
    |> decode.field("value", decode.int)

  case decode.run(body, decoder) {
    Ok(#(name, value)) -> {
      case save_item(ctx.db, id, name, value) {
        Ok(item) -> json_success(encode_item(item), 200)
        Error(e) -> error_to_response(e)
      }
    }
    Error(_) -> json_error("Invalid request body", 422)
  }
}

Database Access Pattern

import gleam/pgo
import gleam/dynamic/decode

pub type Repo {
  Repo(db: pgo.Connection)
}

pub fn connect(database_url: String) -> Repo {
  let config =
    pgo.url_config(database_url)
    |> pgo.pool_size(10)

  let db = pgo.connect(config)
  Repo(db:)
}

pub fn find_user_by_email(
  repo: Repo,
  email: String,
) -> Result(User, AppError) {
  let query = "SELECT id, name, email, role FROM users WHERE email = $1"

  let user_decoder =
    decode.into({
      use id <- decode.parameter
      use name <- decode.parameter
      use email <- decode.parameter
      use role <- decode.parameter
      User(id:, name:, email:, role: parse_role(role))
    })
    |> decode.field(0, decode.int)
    |> decode.field(1, decode.string)
    |> decode.field(2, decode.string)
    |> decode.field(3, decode.string)

  case pgo.execute(query, repo.db, [pgo.text(email)], user_decoder) {
    Ok(pgo.Returned(_, [user])) -> Ok(user)
    Ok(pgo.Returned(_, [])) -> Error(NotFound("user"))
    Error(e) -> Error(DatabaseError(string.inspect(e)))
  }
}

pub fn create_user(
  repo: Repo,
  name: String,
  email: String,
  role: String,
) -> Result(User, AppError) {
  let query =
    "INSERT INTO users (name, email, role) VALUES ($1, $2, $3)
     RETURNING id, name, email, role"

  let user_decoder =
    decode.into({
      use id <- decode.parameter
      use name <- decode.parameter
      use email <- decode.parameter
      use role <- decode.parameter
      User(id:, name:, email:, role: parse_role(role))
    })
    |> decode.field(0, decode.int)
    |> decode.field(1, decode.string)
    |> decode.field(2, decode.string)
    |> decode.field(3, decode.string)

  case pgo.execute(query, repo.db, [pgo.text(name), pgo.text(email), pgo.text(role)], user_decoder) {
    Ok(pgo.Returned(_, [user])) -> Ok(user)
    Error(e) -> Error(DatabaseError(string.inspect(e)))
  }
}

fn parse_role(s: String) -> Role {
  case s {
    "admin" -> Admin
    "moderator" -> Moderator(permissions: [])
    _ -> Member
  }
}

Quick Reference

Common Imports

import gleam/io                          // println, debug
import gleam/int                         // Int operations
import gleam/float                       // Float operations
import gleam/string                      // String operations
import gleam/list                        // List operations
import gleam/dict.{type Dict}            // Dict (hash map)
import gleam/option.{type Option, Some, None}
import gleam/result                      // Result helpers
import gleam/json                        // JSON encode/decode
import gleam/dynamic/decode              // Dynamic value decoding
import gleam/http.{Get, Post, Put, Delete}
import gleam/erlang/process.{type Subject}
import gleam/otp/actor                   // OTP actors

Common Operations Cheat Sheet

// String interpolation (there isn't any — use <>)
"Hello " <> name <> "!"

// Convert to string
int.to_string(42)
float.to_string(3.14)

// Parse
int.parse("42")        // Ok(42)
float.parse("3.14")    // Ok(3.14)

// List operations
list.map(items, fn(x) { x + 1 })
list.filter(items, fn(x) { x > 0 })
list.fold(items, 0, fn(acc, x) { acc + x })
list.find(items, fn(x) { x.id == target_id })

// Dict operations
dict.new()
dict.insert(d, "key", "value")
dict.get(d, "key")     // Result(value, Nil)
dict.from_list([#("a", 1), #("b", 2)])

// Tuples
let pair = #("hello", 42)
pair.0  // "hello"
pair.1  // 42

// Debug print any value
echo some_value

// Assert (crash if pattern doesn't match — use sparingly)
let assert Ok(value) = might_fail()

Last updated: 2026-03-13. Based on Gleam ~1.x, gleam_otp 1.2.0, wisp 2.2.1, lustre 5.6.0.