Error Handling In Gleam

There have been a lot of questions in the Discord about how Gleam error handling works, so I'd like to write a short post describing the current state of error handling in Gleam. We'll cover

  1. Errors as Values / The Result Type
  2. Taking responsibility for errors with case
  3. use syntax, which is not specific to error handling at all but gets used in most Gleam code that handles errors
  4. Propagating errors with result.try
  5. Constructing errors in chains with bool.guard
  6. Putting it all together w/ a custom error type.

Errors as Values

In Gleam, there is no special language level construct or syntax related to representing or handling errors. Gleam applications/libraries generally have a top level error type (or types) which are just normal Gleam custom types.

Functions which can fail return a Result . A Result is a type with two variants (meaning it can be one of two things): an Ok(thing) where thing is what you'd like to return in the success case, or Error(error) where error is what you'd like to return in the failure case.

For example, int.divide in the gleam/int module of Gleam's stdlib has the following function signature:

pub fn divide(dividend: Int, by divisor: Int) -> Result(Int, Nil)

When you try to divide two numbers, you want to get back an Int . But there's a special case: You can't divide by zero! Since this function can fail, you return a Result(Int, Nil) . Our success type is Int and our error type is Nil , so we expect int.divide(6, 2) to return Ok(3) and int.divide(4, 0) to return Error(Nil) .

Taking Responsibility for Errors with case

A case statement is how Gleam does branching. It's roughly equivalent to Rust's match and other similar pattern matching constructs. The idea is that you can pattern match on a value and go down the first branch who's pattern ... well ... matches.

We can get an idea for this simply by looking at how the int.divide function discussed earlier is defined!

pub fn divide(dividend: Int, by divisor: Int) -> Result(Int, Nil) {
  case divisor {
    0 -> Error(Nil)
    divisor -> Ok(dividend / divisor)
  }
}

Pretty simple right? We pattern match on divisor . If the pattern 0 matches, we return an Error(Nil) . Otherwise, we go to the next branch (which matches anything) and do the division which we now know is valid.

We can also use case to take responsibility for an error by matching on a Result . In this example, we "handle" the error by printing the appropriate thing:

case int.divide(8, 0) {
  Ok(n) -> io.println("The answer is: " <> int.to_string(n))
  Error(Nil) -> io.println("Don't divide by zero dumb dumb!!")
}

Side note: In Gleam, 7/0 evaluates to 0 rather than causing a runtime error.

Gleam's case and it's pattern matching in general are pretty powerful, supporting multiple arguments, pattern matching on strings and binary data, guard clauses, all sorts of fun stuff. But anyway, back to error handling.

use syntax: A quick primer.

Technically, use syntax is orthogonal to error handling. You do not have to use use to handle errors properly in Gleam. But in practice, most code does, because it makes things read much nicer.

use is a very simple piece of syntax which confuses most people on their first go at it. Here's what it does:

If a function takes another function as its final argument (often referred to as a "callback"), use syntax flattens out the call so the code doesn't drift right. This turns the highly nested "callback hell" into flat, readable code.

Here are two pieces of code which do exactly the same thing.

list.map([1, 2, 3], fn(n) { 
  n * 2 
}) 

and

use n <- list.map([1, 2, 3])
n * 2

See how the nesting goes away? When we use use a few times in a row, we prevent having extremely nested code. It gets used lots of places in gleam, like middleware and error handling.

If you need time to soak in these two features (case and use ), please take your time and check out the language tour: https://tour.gleam.run/advanced-features/use/)

Propagating Errors with result.try

Sometimes, we don't want to handle an error as soon as we encounter it. Instead, we want to pass any errors up through the call chain and handle them higher up in the application.

To pass the buck in this way, we can use result.try . Functionally, it's very similar to Rust's ? or Zig's try operators. However, it's just a regular function in Gleam. Once upon a time, Gleam had a special keyword for this as well, but with the introduction of the use syntax, we didn't need it anymore.

The function takes two arguments, a Result(T, E) of some kind and a function which says what to do in the success case. In the error case, the function will simply early return for us.

I'm going to show an example in just a moment, but I want to quickly mention one other function.

Constructing Errors with bool.guard

Very often we want to check a given condition and early return if that condition is true. bool.guard provides us the ability to do that without having to use a case statement. It takes two arguments, the predicate to watch out for (a boolean) and what to return if it's true.

Putting it all Together

Alright, let's show that example. We're going to write a simple utility to read in a package's gleam.toml file, parse the file, and return the packages name. Ready?

import gleam/bool
import gleam/result
import gleam/string
import simplifile
import tom

/// This is our custom error type for our package.
/// Functions that return a `Result` should have this as 
/// the error type.
pub type Error {
  ReadTomlError(simplifile.FileError)
  ParseTomlError(tom.ParseError)
  MissingNameError(tom.GetError)
  EmptyNameError
}

pub fn get_package_name() -> Result(String, Error) {
  // We read in the contents of the gleam.toml as a string. 
  // If it fails, we return an appropriate error which wraps
  // the simplifile error for context
  use toml_str <- result.try(
    simplifile.read("./gleam.toml")
    |> result.map_error(ReadTomlError),
  )

  // We try to parse the toml string. If it fails, we return
  // an appropriate error which wraps the tom error for context.
  use parsed_toml <- result.try(
    tom.parse(toml_str)
    |> result.map_error(ParseTomlError),
  )

  // We try to get the name field. If it's missing, we return 
  // an appropriate error, wrapping the tom error for context
  use name <- result.try(
    tom.get_string(parsed_toml, ["name"])
    |> result.map_error(MissingNameError),
  )

  // Now let's say we wanna make sure the package name isn't 
  // an empty string. In reality, the gleam compiler would catch
  // that, but we need an example constructing an error of our own
  use <- bool.guard(string.is_empty(name), Error(EmptyNameError))

  // Everything worked out, so we return the name.
  Ok(name)
}

Take a moment. Really soak it in. If you want to play with the code, I've made it a github repo you can clone here: https://github.com/bcpeinhardt/error_handling_example

Does the code make sense? Here's the gist

  1. Errors are not special in Gleam. Applications usually have their own error types.
  2. If you want to handle an error yourself, you generally match on it with case
  3. If you want to pass handling an error up to someone else, you generally use result.try on the function.
  4. If you want to construct an error and early return it based on some condition, you generally use bool.guard .
  5. If you want your error variant to hold another error as context, make the variant have that error as a field and use result.map_error inside your result.try call.
  6. If you want your error variant to completely replace the original error, use result.replace_error inside your result.try call.

If you understand the provided example code and the checklist above, congratulations, you're going to understand 99% of the error handling code you come across in Gleam.

I hope this helped! And if you still have questions, I encourage you to join Gleam's discord, the folks are very friendly :)

Subscribe to BenIsOnTheInternet

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe