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

1725 lines
39 KiB
Markdown

# 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](#1-language-fundamentals)
2. [OTP from Gleam](#2-otp-from-gleam)
3. [Web Development](#3-web-development)
4. [Package Ecosystem](#4-package-ecosystem)
5. [Interop (Erlang, Elixir, JavaScript)](#5-interop)
6. [Testing](#6-testing)
7. [Project Structure](#7-project-structure)
8. [Real-World Patterns](#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)`.
```gleam
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`.
```gleam
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).
```gleam
// 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
```gleam
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.
```gleam
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.
```gleam
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:**
```gleam
// 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.
```gleam
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
```gleam
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
```gleam
// 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.
```gleam
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.
```gleam
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
```gleam
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)
```gleam
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
```gleam
// 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
```gleam
// src/app/web.gleam
pub type Context {
Context(secret_key_base: String, db: connection)
}
```
#### Routing
```gleam
// 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
```gleam
// 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
```gleam
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`.
```gleam
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.
```gleam
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.
```gleam
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
```gleam
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)
```gleam
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
```toml
# 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"
```
```bash
# 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:
```gleam
// 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/`:
```erlang
%% 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}]).
```
```gleam
// 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:
```gleam
// 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`:
```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:
```javascript
// 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;
}
```
```gleam
// 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
```gleam
// 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.
```gleam
// 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")
}
```
```bash
gleam test
```
### Custom Assertions
```gleam
// 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"
}
}
```
```gleam
// test/string_test.gleam
import test_helpers.{should_contain}
pub fn greeting_test() {
build_greeting("Alice")
|> should_contain("Alice")
|> should_contain("Hello")
}
```
### Testing Actors
```gleam
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
```gleam
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
```toml
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
```gleam
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
```gleam
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
```gleam
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
```gleam
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
```gleam
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
```gleam
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
```gleam
// 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.*