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
- Errors as Values / The
Result
Type - Taking responsibility for errors with
case
use
syntax, which is not specific to error handling at all but gets used in most Gleam code that handles errors- Propagating errors with
result.try
- Constructing errors in chains with
bool.guard
- 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
- Errors are not special in Gleam. Applications usually have their own error types.
- If you want to handle an error yourself, you generally match on it with
case
- If you want to pass handling an error up to someone else, you generally use
result.try
on the function. - If you want to construct an error and early return it based on some condition, you generally use
bool.guard
. - 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 yourresult.try
call. - If you want your error variant to completely replace the original error, use
result.replace_error
inside yourresult.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 :)