Gleam on the BEAM: A Minimal Architecture for Type-Safe Concurrency
Gleam pairs static types and immutable data with BEAM process isolation. This architecture note covers a minimal actor design, Erlang trust boundaries, and operational checks for fault-tolerant services.
13 Apr 2026, 13:01 UTC

Problem & Takeaway
Concurrent services on the Erlang VM must avoid shared mutable state and handle every error path explicitly. Gleam addresses this with a static type system, immutable data by default, and exhaustive pattern matching, while compiling to Erlang so the BEAM's process isolation and supervision trees remain available. The practical takeaway: a small Gleam service can push data-race and unhandled-error classes into compile time, leaving only logic bugs and trust boundaries at the Erlang interop edge.
Requirements
- Compile-time exhaustiveness for all domain states and error variants.
- Immutable data so concurrent processes never share writable memory.
- Isolated processes communicating by message passing.
- A clear trust boundary when calling dynamically typed Erlang code.
- Operational checks that confirm the running system behaves as designed.
Smallest Suitable Design
The smallest design that meets these requirements has four parts: a closed set of custom types for domain messages, exhaustive case expressions over those types, an OTP actor built with gleam_otp that owns mutable state inside a single process, and a supervisor that restarts the actor on failure. No shared global state is required.
Concrete Example: Domain Types and Exhaustive Handling
The following Gleam code defines a request type and a pure handler. It is intentionally free of OTP APIs so the type-system behavior is unambiguous. Verify it with gleam build and gleam test in your project root.
// src/calculator.gleam
pub type Operation {
Add(Int, Int)
Divide(Int, Int)
}
pub fn compute(op: Operation) -> Result(Int, String) {
case op {
Add(a, b) -> Ok(a + b)
Divide(a, b) ->
case b == 0 {
True -> Error("division by zero")
False -> Ok(a / b)
}
}
}
If you remove the Divide branch, gleam build rejects the program with an exhaustiveness error before any bytecode is produced. This is the core safety property: every constructor of Operation must be handled.
Process Isolation with gleam_otp
To move from pure functions to a concurrent service, wrap the handler in an actor. The gleam_otp library provides typed actors on top of BEAM processes. Because the actor API has changed across Gleam releases, confirm the current constructor and callback names in the gleam_otp documentation before integrating it. The architectural rule is unchanged: the actor receives an immutable Operation, calls compute, and replies with a Result. State lives only inside that process.
Trust & Data Boundaries
Gleam code is statically typed, but Erlang is not. When you call an Erlang function, you cross a trust boundary. Use @external to bind the function and treat its return value as unverified until checked.
@external(erlang, "some_erlang_module", "some_function")
pub fn call_external(arg: Int) -> String
The compiler trusts your annotation. If the Erlang function returns a different shape than declared, the failure surfaces at runtime, not compile time. Isolate these calls behind small wrapper modules and validate the result before passing it into typed Gleam code.
Operational Checks
- Type safety: Run
gleam buildin the project root. A clean build proves exhaustiveness and type correctness for all Gleam modules. - Behavior: Run
gleam testto exercise eachOperationvariant and confirm expectedOk/Errorresults. - Runtime: Start the release with
gleam run. Use the BEAM observer (:observer.start()from an Erlang shell attached to the same node) to inspect process counts, message queues, and supervisor restarts.
Failure Modes & Design Shifts
- Missing pattern branch: Caught at compile time. Add the branch; there is no runtime fallback.
- Erlang interop mismatch: The actor crashes. The supervisor restarts it, but the bad message is lost unless you add a dead-letter mechanism. Mitigate by validating external returns immediately.
- Unbounded mailbox: If producers outpace the actor, messages queue in memory. Introduce back-pressure or batch processing rather than sharing state.
- Blocking work: Long-running calls inside the actor block its mailbox. Move them to a separate process or use a pool.
When to Change the Design
- If you need shared mutable state across processes, move that state into a dedicated Erlang
gen_serverand expose typed Gleam wrappers. - For high-throughput pipelines, replace the single actor with a supervision tree of workers and a dispatcher.
- If you must call native code, use Gleam's FFI target modules and keep the unsafe boundary isolated in its own module with explicit documentation.
Limitations & Practical Verification
Gleam's type system does not prevent deadlocks, livelocks, or logic errors. It also does not make Erlang libraries safe; the @external boundary is only as trustworthy as the annotation. To verify the result, confirm that gleam build passes, that gleam test covers every custom-type constructor, and that the observer shows stable process counts under load. If any of these checks fail, revisit the trust boundary or the actor's message protocol before adding features.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.