Using Gleam Opaque Types to Hide Implementation Details
Learn how Gleam opaque types hide implementation details, improve encapsulation, and make refactoring safer, with a concrete counter example and notes on interop and limitations.
28 Jan 2026, 03:19 UTC

The problem: leaking internal representation
When a Gleam module exposes a data type as a plain tuple or record, downstream code can pattern‑match on its fields. This creates tight coupling: a change to the internal shape forces every consumer to update, and mistakes like treating a counter as a raw integer can slip through unnoticed.
Thesis
Gleam’s opaque types let you publish a type name while keeping its underlying representation hidden. Consumers must use the module’s exported functions, which improves encapsulation, stabilises the public API, and makes refactoring safer.
Declaring an opaque type
An opaque type is introduced with the opaque keyword. The public type name is distinct from the concrete type used inside the module.
// counter.gleam
pub opaque Counter :: {Int}
pub fn new() -> Counter {
{0}
}
pub fn inc(c: Counter) -> Counter {
let {n} = c
{n + 1}
}
pub fn value(c: Counter) -> Int {
let {n} = c
n
}
The Counter type is visible to other modules, but its definition ({Int}) is not.
Consuming the opaque type
Downstream code can only interact with a Counter through the functions the module exports. Attempting to inspect or deconstruct the value directly results in a compile‑time error.
// user.gleam
import gleam/counter
pub fn demo() {
let c = counter.new()
// This line will NOT compile:
// let {n} = c // Error: cannot access opaque type Counter
counter.inc(c)
|> counter.value()
|> io.debug
}
The compiler emits a clear message such as "Cannot access opaque type Counter" when pattern‑matching is attempted.
Interop with Erlang/Elixir
When the compiled Gleam code is called from Erlang or Elixir, the opaque boundary disappears: the underlying tuple is visible. To keep the abstraction safe across the language border, you can annotate the Erlang side with @opaque (in Elixir) or rely on documentation, knowing that Gleam’s own type checker will still protect Gleam callers.
# Elixir
{:ok, _} = :gleam.counter.new()
# Returns the raw tuple {0}
This demonstrates that interop exposes the representation, but Gleam code remains guarded.
Trade‑offs and limitations
- Opaque types cannot be used in guard clauses or as map keys because the compiler cannot guarantee a stable underlying representation across modules.
- If you embed an opaque type inside a union type that includes
any()or other dynamic Erlang terms, the safety guarantee can be bypassed. - The module’s public API grows slightly because you must export constructor, accessor, and mutator functions.
These constraints are intentional: they push designers toward thoughtful interfaces rather than leaking implementation details.
Action
Try adding an opaque type to a small library you maintain. Define the type, provide a constructor and a few accessor functions, then attempt to pattern‑match on the type from another module to confirm the compiler error. Observe how the change forces you to think about which operations truly belong in the public API.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.