Handling Errors in Gleam with Result: A Practical Alternative to Exceptions
Gleam replaces thrown exceptions with a pure Result type and a try/catch syntax that pattern‑matches on Ok/Err. This blog walks through a file‑reading pipeline, shows the generated code, and discusses verbosity, bundle size, and JS interop trade‑offs.
01 Mar 2026, 17:59 UTC

The problem with thrown exceptions
Most JavaScript‑derived languages rely on throw to signal failure. In an asynchronous pipeline this forces you to sprinkle try/catch blocks everywhere, breaking referential transparency and making composition fragile. When a library you depend on throws, the error surface expands silently, and static analysis can’t help you.
Gleam’s Result type as a pure alternative
Gleam ships a single algebraic data type for fallible operations:
pub type Result(value, error) {
Ok(value)
Err(error)
}
Every function that can fail returns a Result. The caller decides what to do with Ok or Err – no hidden control flow, no stack unwinding. Because Result is a regular type, it works with the usual combinators (map, bind, map_error) and with pattern matching.
Linear code with try/catch syntax
Gleam adds a lightweight try expression that pattern‑matches on Result and lets you write sequential code:
pub fn read_config() -> Result(Config, FileError) {
try {
let contents = File.read!("config.json")
let parsed = Json.decode(contents)
Ok(parsed)
} catch err -> Err(err)
}
The try block evaluates each line; if any expression yields Err, the block short‑circuits to the catch clause. The generated JavaScript (or Erlang) contains explicit Result checks, not native try/catch, preserving the pure error model.
Worked example: a file‑reading pipeline
Below is a minimal Gleam project that reads a JSON file, validates it, and returns a typed config record.
- Create the project (run in a terminal with write access to your workspace):
gleam new config_reader cd config_reader - Add the
gleam_jsondependency ingleam.toml:[dependencies] gleam_json = "~> 1.0" - Implement the pipeline in
src/config_reader.gleam:import gleam/io import gleam/result import gleam/json import gleam/file pub type Config { Config(port: Int, host: String) } pub fn load_config(path: String) -> Result(Config, String) { try { let raw = file.read(path) // Result(String, FileError) let text = result.map(raw, fn(b) { io.utf8_decode(b) }) let json = json.decode(text, json.object([ field("port", json.int), field("host", json.string) ])) let cfg = result.map(json, fn(o) { Config(port: o.port, host: o.host) }) Ok(cfg) } catch err -> Err(inspect(err)) } pub fn main() { case load_config("config.json") { Ok(cfg) -> io.println("Loaded: " <> inspect(cfg)) Err(e) -> io.println("Failed: " <> e) } } - Build and run (requires a
config.jsonfile in the project root):gleam build # Expected: compiled JavaScript in build/dev/javascript/ gleam run # runs the generated JS via NodeIf the file exists and is valid JSON, you’ll see
Loaded: Config(port: 8080, host: "localhost"). Missing or malformed files produceFailed: …with a readable description.
Trade‑offs and limitations
- Verbosity: each fallible step must be wrapped or combined with
Result.map/bind. In large pipelines the code grows compared to a singletry/catchblock. - Bundle size: front‑end targets (JavaScript) carry the
Resultruntime and combinators, adding a few kilobytes. For tiny widgets this may matter. - Interop friction: calling a JavaScript library that throws re‑introduces exception‑based flow. Wrap such calls in a small Gleam shim that catches and returns
Result. - Learning curve: compiler error messages reference
Resultconstructors rather than line numbers of a thrown exception, which can feel opaque at first.
How to verify the approach in your own codebase
- Run
gleam repland evaluate a tinytryblock:
Confirm the REPL prints> try { Ok(1) } catch e -> Err(e) Ok(1) : Result(Int, Nil)OkorErrwithout throwing. - Add
--warn-unused-result(or the equivalent flag ingleam.toml) to get compiler warnings when aResultis ignored. - Inspect the generated JavaScript (
build/dev/javascript/config_reader.mjs) – you should see explicitif (result.tag === "Ok") … else …branches, not nativetry/catch.
Actionable next step
Pick one service boundary (e.g., HTTP request handling, file I/O, or a third‑party SDK) and rewrite its error path to return Result. Enable the unused‑result warning, run the test suite, and measure the reduction in uncaught‑exception logs. The change is reversible – you can always fall back to a thin wrapper that throws – but the default becomes explicit, composable, and statically checked.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.