Flattening Gleam Result Chains with the use Keyword
Gleam's `use` keyword turns nested Result case expressions into flat, readable sequences. See how it desugars, where it helps, and what to watch for.
25 Aug 2025, 18:35 UTC

If you have written more than a few lines of Gleam, you have probably built a Result pyramid: a function that calls three fallible operations and ends up looking like a set of nested Russian dolls. Each case peels one layer, and by the time you reach the success path, your code is indented halfway across the screen. Gleam's use keyword exists to flatten exactly that shape. It lets you write sequential code that reads top-to-bottom while still returning the first Error automatically. This post looks at how use works with Result, what it actually compiles to, and where the ergonomics stop being helpful.
The nested Result pyramid
Gleam has no exceptions. Fallible operations return a Result, a custom type with two variants: Ok(value) for success and Error(reason) for failure. That explicitness is one of the language's strengths, but it has a cost. When you chain several Result-returning calls, the straightforward approach produces deep nesting:
pub fn process(input: String) -> Result(String, String) {
case parse_int(input) {
Error(e) -> Error(e)
Ok(n) ->
case double(n) {
Error(e) -> Error(e)
Ok(d) ->
case ensure_positive(d) {
Error(e) -> Error(e)
Ok(p) -> Ok(int_to_string(p))
}
}
}
}
Every step repeats the same Error(e) -> Error(e) boilerplate. The logic is simple, but the shape obscures it.
Flattening the chain with use
The use keyword is syntactic sugar for early return. When you write:
use n <- result.try(parse_int(input))
Gleam expands it into a case expression. If parse_int(input) is an Error, the whole function returns that error immediately. If it is Ok(n), execution continues with n bound and the rest of the function body acting as the callback. Because use desugars to ordinary case expressions, there is no runtime cost: the generated code is the same nested structure you would have written by hand, just without the indentation.
The same process function becomes:
pub fn process(input: String) -> Result(String, String) {
use n <- result.try(parse_int(input))
use d <- result.try(double(n))
use p <- result.try(ensure_positive(d))
Ok(int_to_string(p))
}
Three lines of intent, no ceremony.
Worked example: validating and transforming input
Let's make this concrete. These examples assume a recent stable Gleam toolchain where result.try is available in the standard library. Create a scratch project in a clean directory—running gleam new inside an existing project will fail or create nested directories:
gleam new result-demo
cd result-demo
Open src/result_demo.gleam (the module name matches your project name) and replace the contents with:
import gleam/int
pub fn parse_int(s: String) -> Result(Int, String) {
int.parse(s)
}
pub fn double(n: Int) -> Result(Int, String) {
Ok(n * 2)
}
pub fn ensure_positive(n: Int) -> Result(Int, String) {
case n > 0 {
True -> Ok(n)
False -> Error("value must be positive")
}
}
pub fn process(input: String) -> Result(String, String) {
use n <- result.try(parse_int(input))
use d <- result.try(double(n))
use p <- result.try(ensure_positive(d))
Ok(int.to_string(p))
}
pub fn main() {
let ok = process("21")
let err = process("-5")
let err2 = process("abc")
echo ok
echo err
echo err2
}
Run it from the project root:
gleam run
Expected checks: the first echo prints Ok("42"). The second prints Error("value must be positive") because -5 doubles to -10, failing the positivity check. The third prints an Error from the integer parser. The key behavior is short-circuiting: when parse_int("abc") fails, double and ensure_positive never run.
To confirm the desugaring, compile to Erlang and inspect the output:
gleam build -t erlang
Look under build/erlang/ (or your configured output directory) for the generated result_demo.erl. You will see nested case clauses matching the pyramid above. This requires an installed Erlang/OTP runtime; if it is missing, the build will fail with a toolchain error, not a code error.
Trade-offs and limitations
use is not free of friction.
Implicit control flow. Because the Error path is handled by the expansion, a reader must know that use short-circuits. During debugging, stack traces may not point to the exact use line that triggered the return. Keeping Error payloads descriptive—"value must be positive" rather than a bare Nil—mitigates this.
Error type alignment. Every function in the chain must return a Result whose error type is compatible. If parse_int returns Result(Int, String) and another step returns Result(Int, MyError), the compiler will reject the chain. You must normalize errors first, for example with result.map_error, before use can bind them.
Not a universal tool. use fits functions that accept a callback as their final argument. It is the right choice for Result pipelines, but it is not a general-purpose replacement for every case expression.
A practical next step
Pick one deeply nested Result chain in your current codebase and rewrite it with use. Run your existing tests to confirm behavior is unchanged. If you want to verify the performance claim yourself, build to your production target and diff the generated code against the hand-written version. You should see the same nested case structure—proving that the readability gain is purely syntactic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.