Designing a Safe Boundary Between Gleam and Erlang Libraries
An architecture note on wrapping Erlang libraries from Gleam: private @external declarations, validating wrappers, tagged-tuple error contracts, and when to switch to OTP actors instead.
29 Jul 2025, 10:58 UTC

The problem
You want to use an existing Erlang or Elixir library from a Gleam service — for example, an HTTP server or a parser that has no Gleam equivalent — without giving up the type safety that made you choose Gleam. The catch: Gleam's external function mechanism is an unchecked contract. The compiler trusts the signature you write and cannot verify it against the Erlang module. A wrong signature can crash the BEAM VM or silently corrupt data. This note describes the smallest design that keeps that risk contained.
Requirements
- Call a known set of Erlang functions from Gleam code.
- Keep raw external declarations out of application code; the rest of the codebase should see ordinary typed Gleam functions.
- Represent failures as Gleam
Resultvalues wherever the Erlang side can fail in a recoverable way. - Make the external signatures easy to audit when the Erlang library changes.
Smallest suitable design
One module per wrapped library, with two layers inside it:
- Private external declarations, using the
@externalattribute. This is the actual Gleam syntax:
// src/my_service/http.gleam
// Maps to erlang:list_to_binary/1
@external(erlang, "erlang", "list_to_binary")
fn list_to_binary(chars: List(Int)) -> String
- Public wrapper functions that validate inputs, call the externals, and convert results into Gleam types:
pub fn to_binary(chars: List(Int)) -> Result(String, Nil) {
case valid_codepoints(chars) {
True -> Ok(list_to_binary(chars))
False -> Error(Nil)
}
}
The public wrapper is the only thing the rest of the application imports. External declarations stay private so an incorrect signature can only be exercised through code that validates its inputs first.
One important limitation to state plainly: Gleam has no exception-handling syntax. If the Erlang function raises, exits, or throws, a plain external call will propagate that through your Gleam process like any other BEAM exception — it will not become a Result. If you need to convert Erlang exceptions into values, use a rescue-style helper such as the one provided by the gleam_erlang package (check its current API before relying on it, since package APIs evolve), or wrap the call in a small Erlang module you control that catches the exception and returns a tagged tuple:
%% src/my_service_http_erl.erl — plain Erlang shim you own
-module(my_service_http_erl).
-export([safe_call/2]).
safe_call(Mod, Args) ->
try {ok, apply(Mod, some_fun, Args)}
catch Class:Reason -> {error, {Class, Reason}}
end.
Then declare an external against your shim, not the third-party library. This gives you a stable, reviewable contract even when the underlying library's error behaviour is messy.
Trust and data boundaries
The external layer is the trust boundary. Treat it accordingly:
- Data entering Erlang: Gleam strings become binaries, ints become integers, lists become lists, and custom types become tagged tuples. If a value cannot be represented losslessly (for example, an Erlang API that expects specific atoms), construct those atoms only inside the wrapper — never let arbitrary input become an atom, because atoms are not garbage-collected and unbounded atom creation can exhaust the VM's atom table.
- Data leaving Erlang: pattern-match the returned term against the exact shapes you expect. Anything unexpected becomes an
Errorwith enough context to debug. Do not model the return type as broader than reality "just in case" — that hides mismatches. - Keep the boundary thin. Only primitives and simple structures cross it. Business logic, retries, and policy stay in pure Gleam where the type system can help you.
Operational checks
- Compile: run
gleam build(orgleam check). Be aware of what this does and does not prove: the compiler checks that your Gleam code is internally consistent, but it cannot verify that an external signature matches the real Erlang function. A typo in the module or function name typically surfaces only at runtime as anundeferror. - Unit tests: for each wrapper, test the happy path and at least one input the wrapper must reject before crossing the boundary. If you use an Erlang shim that catches exceptions, also test a case that forces the Erlang side to raise, and assert the shim returns the tagged error tuple.
- Staging observation: run the service under realistic load and watch the BEAM for crashes (crash reports in the log, or the observer tool). Because exceptions are not caught by Gleam itself, a missing rescue path shows up as process crashes — which is exactly what you want to detect here rather than in production.
Failure modes
- Signature drift: the Erlang library changes arity or argument order in an upgrade. Nothing at compile time warns you. Mitigation: pin dependency versions, and keep the externals in one small module so a version bump has exactly one audit site.
- Uncaught exceptions: an Erlang function raises in a path you didn't wrap. The calling Gleam process crashes. Under OTP supervision this is often acceptable — the supervisor restarts the process — but only if you've decided that deliberately rather than by omission.
- Silent misinterpretation: a term that is structurally valid but semantically wrong (e.g., a list interpreted as the wrong tuple shape). Mitigation: strict pattern matching on returns, and tagged tuples (
{ok, Value}/{error, Reason}) rather than bare values across the boundary.
Conditions that would change the design
- Stateful or asynchronous Erlang APIs (gen_servers, processes that send you messages): plain external functions are the wrong tool. Move to Gleam's OTP abstractions (the
gleam/otpactor and supervisor modules) and model the Erlang process as an actor with typed messages, or write a small Erlang gen_server you own and talk to it through a narrow message protocol. - High call volume on a hot path: if profiling shows the conversion layer is expensive, you might push validation into the Erlang shim — but only after measuring, since that moves logic out of the type-checked world.
- A native Gleam library appears for the same capability: prefer it and delete the boundary entirely. FFI wrappers are a bridge, not a destination.
Limitations
This design reduces the blast radius of FFI mistakes but cannot eliminate them: the external signatures remain an unverified promise. The practical safeguards are convention (one module per library, private externals), tagged-tuple contracts, and tests that exercise the failure paths. Verify the current syntax and the gleam_erlang API against the Gleam version you actually run — the language and standard packages have changed across releases, and examples here reflect the attribute-based external syntax used by current Gleam, which you should confirm with gleam --version and the language tour for your release.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.