Why Gleam's Result Type Changes How You Handle Failure in Production
Gleam's Result type replaces exceptions with compile-time-enforced Ok/Err handling. This post shows how pipeline operators compose failures safely, where JavaScript interop breaks the guarantee, and how to verify the compiler actually catches missing branches.
23 Jan 2026, 13:48 UTC

The problem: exceptions don't compose
You're building a service that calls three downstream APIs, transforms the data, and writes to a database. In JavaScript or Python, each call might throw. You wrap them in try/catch, but the error types are any, the stack traces are noisy, and a missed catch crashes the request—or worse, the whole BEAM process. The compiler won't tell you that you forgot to handle the timeout case from the third API.
Gleam's Result replaces that uncertainty with a compile-time guarantee: every function that can fail returns Result(Ok, Err), and the compiler forces you to handle both branches before the code runs.
What Result actually is
Result is an algebraic data type with two constructors:
pub type Result(value, error) {
Ok(value)
Err(error)
}
A function that reads a config file might return Result(Config, FileError). A function that parses JSON returns Result(Data, DecodeError). The type signature tells you exactly what can go wrong—no throws clause, no documentation hunt.
Composing without losing safety
Gleam gives you pipeline operators that thread Result values through a chain:
maptransforms theOkvalue, leavesErraloneand_thenruns a function that returns anotherResult, flattening the nestor_elserecovers from anErrby supplying a fallbackResult
Here's a worked example: fetching a user, then their posts, then rendering HTML. Each step can fail.
import gleam/http/client
import gleam/json
import gleam/result
pub type User { User(id: Int, name: String) }
pub type Post { Post(id: Int, title: String) }
pub fn fetch_user(id: Int) -> Result(User, HttpError) {
// ... HTTP call returning Result
}
pub fn fetch_posts(user_id: Int) -> Result(List(Post), HttpError) {
// ... HTTP call returning Result
}
pub fn render_profile(user: User, posts: List(Post)) -> Result(String, TemplateError) {
// ... template rendering
}
pub fn build_profile(user_id: Int) -> Result(String, ProfileError) {
fetch_user(user_id)
|> result.map_error(ProfileError.UserFetchFailed)
|> result.and_then(fn(user) {
fetch_posts(user.id)
|> result.map_error(ProfileError.PostsFetchFailed)
|> result.map(fn(posts) { render_profile(user, posts) })
})
|> result.flatten()
}
Notice: map_error converts the low-level HttpError into a domain-specific ProfileError variant. The caller of build_profile sees one unified error type and must pattern-match on every variant. No surprise undefined at runtime.
Where the abstraction leaks
The guarantee holds only inside Gleam code. When you call JavaScript libraries—whether on the Node target or the browser—you must wrap the call yourself:
@external(javascript, "some-lib", "parse")
fn js_parse(input: String) -> Result(Parsed, JsError)
If the external function throws instead of returning a Result, the exception propagates past Gleam's type system and becomes a runtime panic. The same risk exists when using Erlang/Elixir NIFs that raise. The fix is discipline: every FFI boundary gets a thin wrapper that catches and converts to Err.
Verifying the guarantee yourself
You don't have to trust the blog post. Create a new Gleam project and add a function that returns Result(String, String) but only handles Ok:
gleam new demo
cd demo
# edit src/demo.gleam
pub fn main() {
let result = Ok("hello")
case result {
Ok(msg) -> io.println(msg)
// missing Err branch
}
}
# gleam build
# → Compile error: pattern match is not exhaustive
The compiler stops you before the artifact is produced. Run gleam test with a test that omits an Err branch and you'll see the same failure in CI.
Trade-off: verbosity for visibility
You write more lines—explicit Ok/Err constructors, map_error conversions, pattern matches. In return, the failure modes are documented in the type signature and verified by the compiler. For a team that deploys to BEAM where a single unhandled error can take down a supervision tree, that trade-off pays for itself in fewer 3 AM pages.
Start small, stay consistent
Pick one module—maybe the HTTP client wrapper—and migrate its return types to Result. Add map_error at the boundary so the rest of the codebase sees domain errors, not transport errors. Run gleam check after each change. Once the pattern is familiar, expand outward. The compiler will keep you honest every step of the way.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.