error_union
Layer-aware error handling for Gleam.
Chain fallible steps with use <- try(...) just like gleam/result.try — but
each step keeps its own error type, a failure remembers which step it
came from, and you deal with every layer's error in one place.
gleam add error_union
result.try vs error_union.try
Say you have two fallible steps whose errors have different types:
import gleam/int
// step 1: parses an int, error type is Nil
fn parse_int(s: String) -> Result(Int, Nil) {
int.parse(s)
}
// step 2: checks a number is positive, error type is String
fn ensure_positive(n: Int) -> Result(Int, String) {
case n > 0 {
True -> Ok(n)
False -> Error("must be positive")
}
}
Official result.try: all steps must share one error type
import gleam/result
pub fn parse_and_check(s: String) -> Result(Int, String) {
use a <- result.try(
parse_int(s) |> result.map_error(fn(_) { "not an int" }), // Nil -> String
)
use b <- result.try(ensure_positive(a))
Ok(b)
}
Every step must share the same error type, so step 1 needs a manual
map_error shim — and the resulting Error("not an int") is just a String
that tells you nothing about which step failed or what its real error was.
error_union.try: each layer keeps its own error type
import error_union.{deal, try}
pub fn parse_and_check(s: String) {
use a <- try(parse_int(s)) // step 1 error stays Nil
use b <- try(ensure_positive(a)) // step 2 error stays String
Ok(b)
}
pub fn handle(s: String) -> String {
case parse_and_check(s) {
Ok(n) -> "ok: " <> int.to_string(n)
Error(eu) -> {
// one `use <- deal` per `use <- try` layer
use eu <- deal(eu, fn(_) { "not an int" }) // layer 1
use eu <- deal(eu, fn(e) { "not positive: " <> e }) // layer 2
eu //unreachable required by the type checker
}
}
}
pub fn main() {
handle("42") // -> "ok: 42"
handle("0") // -> "not positive: must be positive"
handle("x") // -> "not an int"
}
How it works
Eu(a, b) is a two-slot error bag:
- slot
a— the error of this step (theResultyou passed in) - slot
b— the error of the following steps (usually another nestedEu)
try(r, f) wraps failures into Eu:
risError(e)→Eu([e], [])— this step failedrisOkbutffails →Eu([], [e])— a later step failed
deal(eu, current, sequential) unwraps one layer: an error in slot a goes to
current, an error in slot b goes to sequential — which is where the next
use <- deal picks it up. That's why the number of use <- deal lines matches
the number of use <- try lines.
Note:
use eu <- deal(eu, ...)shadowseuso each layer keeps unwrapping. Writing the finaleuvalue is required by the type checker — it can only be reached if the whole chain somehow returnsErrorat the very end.
Throwing your own value at the end of the chain
The last block of a use chain doesn't have to end in Ok — return
Error(anything) and that value lands in the innermost slot, recoverable once
deal has unwrapped every layer.
import error_union.{deal, try}
import gleam/int
pub fn main() {
let result = {
use a <- try(Ok(1)) // this layer passes
use b <- try(Ok(2)) // and this one too
Error(#("boom", a + b)) // throw your own value instead
}
let handled = case result {
Ok(_) -> "unexpected ok"
Error(eu) -> {
use eu <- deal(eu, fn(_) { "unreachable" })
use eu <- deal(eu, fn(_) { "unreachable" })
eu.0 <> " err value is " <> int.to_string(eu.1) // eu : #(String, Int)
}
}
// handled == "boom err value is 3"
}
Development
gleam test # Run the tests
Further documentation can be found at https://error-union.hexdocs.pm/.