Gleam Generics: Compile-Time Safety Without Runtime Type Checks
How parametric polymorphism in Gleam lets you write reusable, type-safe code on the BEAM, with a worked Stack example and practical interop notes.
27 Mar 2026, 16:36 UTC

When you build a data structure on the BEAM, you face a choice. Write it in Erlang and you get dynamic dispatch and fast development, but nothing stops a caller from pushing a string onto a stack of integers. Write it in a statically typed language and you might pay for runtime type tags or boxing that feel foreign on the Erlang virtual machine. Gleam offers a third path: parametric polymorphism, where you write the logic once and the compiler proves it is safe for every type you use it with.
The useful takeaway is that Gleam generics are not a runtime abstraction. They are a compile-time contract. Once the build succeeds, the generated BEAM bytecode contains no extra type metadata, so generic code interoperates with Erlang as naturally as hand-written modules.
What parametric polymorphism looks like in Gleam
In Gleam, a generic type or function declares its type parameters in parentheses. The standard library uses this everywhere: List(a) is a list of any type a, and Result(ok, err) separates success values from error values. When you write list.map(my_list, fn(x) { x + 1 }), the compiler infers that a is an integer and rejects the code if the list actually contains strings.
Most of the time you can rely on inference. Explicit annotations, however, document intent and turn a vague compiler error into a precise one. They also help when you expose a public API and want to pin down the contract for other developers.
A worked example: a generic stack
Suppose you need a last-in, first-out structure that could hold integers, strings, or user records. Here is a minimal generic stack in Gleam:
pub type Stack(a) {
Empty
Push(a, Stack(a))
}
pub fn push(stack: Stack(a), item: a) -> Stack(a) {
Push(item, stack)
}
pub fn pop(stack: Stack(a)) -> Result(#(a, Stack(a)), Nil) {
case stack {
Empty -> Error(Nil)
Push(item, rest) -> Ok(#(item, rest))
}
}
The type parameter a appears in the constructor Push and in the function signatures. Pattern matching works exactly as it does on non-generic types: the case expression deconstructs the stack without caring what a concretely is.
You can now create stacks of different types in the same project:
let int_stack = push(push(Empty, 1), 2)
let str_stack = push(Empty, "hello")
If you later try to mix them—say, pushing an integer onto str_stack—the compiler rejects the program before it ever reaches the BEAM.
Type erasure and Erlang interop
Gleam implements generics through type erasure. At runtime, Stack(Int) and Stack(String) are represented by the same BEAM terms. There are no hidden wrappers, no reflection tables, and no dynamic type checks. This keeps the memory footprint and execution speed comparable to equivalent Erlang code.
Because the runtime representation is plain Erlang terms, Erlang modules can call your Gleam functions directly. An Erlang process can invoke my_module:push(SomeStack, SomeItem) and the BEAM will accept any term for SomeItem. The generic constraint lives only in the Gleam compiler; Erlang has no way to enforce it. That is the core trade-off: safety is absolute at compile time and absent at runtime.
Limitations and practical trade-offs
The most important limitation follows directly from erasure. You cannot ask a generic value what type it holds at runtime. A function like describe(value: a) -> String cannot inspect a and branch on whether it is an integer or a list. If you need that behavior, you must model it explicitly with a custom type that carries variants, not with a bare type parameter.
Version matters here. Gleam releases before 0.20 did not infer type parameters in every generic context, so code written for older toolchains may require explicit annotations that modern versions accept implicitly. If you maintain a library that supports older compilers, annotate exported function arguments and return types rather than relying on inference alone.
Verbosity is another minor cost. A deeply nested generic type such as Result(List(#(String, Option(Int))), String) is hard to read. Type aliases reduce that noise:
pub type ParseResult = Result(List(#(String, Option(Int))), String)
Use aliases at module boundaries to keep signatures readable without changing the underlying type.
Check your work
You can verify these behaviors in a scratch project. Run the following commands in your project root, where you have write access to the filesystem:
gleam --version— confirm you are on 0.20 or newer so inference behaves as described.gleam build— compile the stack example above. Expected check: the build succeeds with no errors.- Change
push(Empty, 1)topush(\"hello\", 1)and rungleam buildagain. Expected check: compilation fails with a type mismatch, proving the constraint is enforced before deployment. gleam testandgleam format— run your test suite and formatter to ensure style and behavior stay consistent as you add generic modules.
If you interoperate with Erlang, remember that the Erlang side bypasses Gleam's checks. Document the expected term shapes in your Erlang-facing modules, or wrap them in a Gleam module that validates input at the boundary.
Generics in Gleam are not a new abstraction layer; they are a way to move safety earlier in the development cycle. Start by replacing one dynamically shaped helper in your codebase with a generic version, annotate its public signature, and let the compiler tell you where the assumptions break.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.