Using Gleam’s Opaque Types for Safer Library APIs
Learn how Gleam’s opaque types hide constructors, enforce compile‑time encapsulation, and let you evolve library internals without breaking user code.
12 Oct 2026, 13:42 UTC

The problem: accidental exposure of internal data
When you publish a Gleam library, users often need a simple identifier or wrapper type. If you expose the constructor directly, callers can pattern‑match on it or build values in ways you didn’t intend. Later, when you want to add a field or change the representation, every dependent project must update its code, even if the public API hasn’t changed.
Thesis
Gleam’s pub opaque feature lets you hide a type’s constructor while still providing a typed value. This gives compile‑time encapsulation, lets you evolve the internal representation without breaking source‑level compatibility, and guides users to interact with your type only through the functions you export.
How opaque types work
An opaque type is declared like:
pub opaque type Id = Id#Internal
The #Internal constructor is visible only inside the defining module. Outside that module the compiler treats Id as an abstract value: you can pass it around, store it in Result or Option, but you cannot create or inspect it except via functions you export.
Worked example
We’ll create a tiny library that models a user identifier.
Create a new project:
gleam new opaque_demo cd opaque_demoYou need write permission to the directory where you run these commands.
Add the identifier module (
src/user_id.gleam):pub opaque type Id = Id#Internal pub fn new_id(int) -> Id { Id#Internal } pub fn value(id: Id) -> int { // In a real library you might validate or transform here let Id#Internal = id Internal }Note that the pattern match on
Id#Internal appears only inside this module, where the constructor is in scope.Use the type from another module (
src/main.gleam):import gleam/io import opaque_demo/user_id pub fn main() { let id = user_id.new_id(42) let num = user_id.value(id) io.println(num) // Uncommenting the next line causes a compile‑time error: // case id { Id#Internal -> ... } }
To verify the encapsulation, run:
gleam build
The build succeeds because main.gleam only calls the exported functions. If you uncomment the pattern match, gleam build fails with an error similar to:
error: Constructor `Id#Internal` is not in scope
--> src/main.gleam:8:13
|
8 | case id { Id#Internal -> ... }
| ^^^^^^^^^^^^^
This demonstrates that callers cannot reach the hidden constructor.
Trade‑off: evolution vs. recompilation
Changing the internal representation (e.g., making Id a tuple {Int, Int}) does not require changes to dependent source code as long as the public functions keep the same signatures. However, the Gleam compiler still emits a new binary layout, so any project that depends on the library must be recompiled to pick up the updated layout. This is a typical trade‑off for opaque types: source compatibility is preserved, but binary compatibility still demands a rebuild.
You can check this by:
- Modify
src/user_id.gleamto change the definition, e.g.: - Run
gleam buildagain. The dependentmain.gleamcompiles without edits. - Run the resulting executable to confirm it still works.
pub opaque type Id = Id#Internal(Int, Int)
pub fn new_id(i) -> Id {
Id#Internal(i, 0)
}
pub fn value(id: Id) -> Int {
let Id#Internal(a, _) = id
a
}
Actionable closing
If you are maintaining a Gleam library and want to protect callers from unintended use of your data structures, start by marking the type as pub opaque. Provide a constructor function and any needed accessors, then avoid exporting the constructor itself. This gives you the freedom to refactor the internals while keeping your users’ code stable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.