- 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
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
- Language Fundamentals
- OTP from Gleam
- Web Development
- Package Ecosystem
- Interop (Erlang, Elixir, JavaScript)
- Testing
- Project Structure
- 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.