Recoverable Errors with Result
Most errors aren’t serious enough to require the program to stop entirely. Sometimes, when a function fails, it’s for a reason that you can easily interpret and respond to. For example, if you try to add two large integers and the operation overflows because the sum exceeds the maximum representable value, you might want to return an error or a wrapped result instead of causing undefined behavior or terminating the process.
The Result Enum
Recall from Generic data types section in Chapter
8 that the Result enum is defined as having
two variants, Ok and Err, as follows:
enum Result<T, E> {
Ok: T,
Err: E,
}
The Result<T, E> enum has two generic types, T and E, and two variants:
Ok which holds the value of type T and Err which holds the value of type
E. This definition makes it convenient to use the Result enum anywhere we
have an operation that might succeed (by returning a value of type T) or fail
(by returning a value of type E).
The ResultTrait
The ResultTrait trait provides methods for working with the Result<T, E>
enum, such as unwrapping values, checking whether the Result is Ok or Err,
and panicking with a custom message. The ResultTraitImpl implementation
defines the logic of these methods.
trait ResultTrait<T, E> {
fn expect<+Drop<E>>(self: Result<T, E>, err: felt252) -> T;
fn unwrap<+Drop<E>>(self: Result<T, E>) -> T;
fn expect_err<+Drop<T>>(self: Result<T, E>, err: felt252) -> E;
fn unwrap_err<+Drop<T>>(self: Result<T, E>) -> E;
fn is_ok(self: @Result<T, E>) -> bool;
fn is_err(self: @Result<T, E>) -> bool;
}
The expect and unwrap methods are similar in that they both attempt to
extract the value of type T from a Result<T, E> when it is in the Ok
variant. If the Result is Ok(x), both methods return the value x. However,
the key difference between the two methods lies in their behavior when the
Result is in the Err variant. The expect method allows you to provide a
custom error message (as a felt252 value) that will be used when panicking,
giving you more control and context over the panic. On the other hand, the
unwrap method panics with a default error message, providing less information
about the cause of the panic.
The expect_err and unwrap_err methods have the exact opposite behavior. If
the Result is Err(x), both methods return the value x. However, the key
difference between the two methods is in case of Ok(). The expect_err method
allows you to provide a custom error message (as a felt252 value) that will be
used when panicking, giving you more control and context over the panic. On the
other hand, the unwrap_err method panics with a default error message,
providing less information about the cause of the panic.
A careful reader may have noticed the <+Drop<T>> and <+Drop<E>> in the first
four methods signatures. This syntax represents generic type constraints in the
Cairo language, as seen in the previous chapter. These constraints indicate that
the associated functions require an implementation of the Drop trait for the
generic types T and E, respectively.
Finally, the is_ok and is_err methods are utility functions provided by the
ResultTrait trait to check the variant of a Result enum value.
is_oktakes a snapshot of aResult<T, E>value and returnstrueif theResultis theOkvariant, meaning the operation was successful. If theResultis theErrvariant, it returnsfalse.is_errtakes a snapshot of aResult<T, E>value and returnstrueif theResultis theErrvariant, meaning the operation encountered an error. If theResultis theOkvariant, it returnsfalse.
These methods are helpful when you want to check the success or failure of an
operation without consuming the Result value, allowing you to perform
additional operations or make decisions based on the variant without unwrapping
it.
You can find the implementation of the ResultTrait here.
It is always easier to understand with examples. Have a look at this function signature:
fn u128_overflowing_add(a: u128, b: u128) -> Result<u128, u128>;
It takes two u128 integers, a and b, and returns a Result<u128, u128>
where the Ok variant holds the sum if the addition does not overflow, and the
Err variant holds the overflowed value if the addition does overflow.
Now, we can use this function elsewhere. For instance:
fn u128_checked_add(a: u128, b: u128) -> Option<u128> {
match u128_overflowing_add(a, b) {
Ok(r) => Some(r),
Err(r) => None,
}
}
Here, it accepts two u128 integers, a and b, and returns an
Option<u128>. It uses the Result returned by u128_overflowing_add to
determine the success or failure of the addition operation. The match
expression checks the Result from u128_overflowing_add. If the result is
Ok(r), it returns Some(r) containing the sum. If the result is Err(r), it
returns None to indicate that the operation has failed due to overflow. The
function does not panic in case of an overflow.
Let's take another example:
fn parse_u8(s: felt252) -> Result<u8, felt252> {
match s.try_into() {
Some(value) => Ok(value),
None => Err('Invalid integer'),
}
}
In this example, the parse_u8 function takes a felt252 and tries to convert
it into a u8 integer using the try_into method. If successful, it returns
Ok(value), otherwise it returns Err('Invalid integer').
Our two test cases are:
fn parse_u8(s: felt252) -> Result<u8, felt252> {
match s.try_into() {
Some(value) => Ok(value),
None => Err('Invalid integer'),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_felt252_to_u8() {
let number: felt252 = 5;
// should not panic
let res = parse_u8(number).unwrap();
}
#[test]
#[should_panic]
fn test_felt252_to_u8_panic() {
let number: felt252 = 256;
// should panic
let res = parse_u8(number).unwrap();
}
}
Don't worry about the #[cfg(test)] attribute for now. We'll explain in more
detail its meaning in the next Testing Cairo Programs chapter.
#[test] attribute means the function is a test function, and #[should_panic]
attribute means this test will pass if the test execution panics.
The first one tests a valid conversion from felt252 to u8, expecting the
unwrap method not to panic. The second test function attempts to convert a
value that is out of the u8 range, expecting the unwrap method to panic with
the error message Invalid integer.
Propagating Errors
When a function’s implementation calls something that might fail, instead of handling the error within the function itself you can return the error to the calling code so that it can decide what to do. This is known as propagating the error and gives more control to the calling code, where there might be more information or logic that dictates how the error should be handled than what you have available in the context of your code.
For example, Listing 9-1 shows an implementation of a
function that tries to parse a number as u8 and uses a match expression to
handle a potential error.
// A hypothetical function that might fail
fn parse_u8(input: felt252) -> Result<u8, felt252> {
let input_u256: u256 = input.into();
if input_u256 < 256 {
Result::Ok(input.try_into().unwrap())
} else {
Result::Err('Invalid Integer')
}
}
fn mutate_byte(input: felt252) -> Result<u8, felt252> {
let input_to_u8 = match parse_u8(input) {
Result::Ok(num) => num,
Result::Err(err) => { return Result::Err(err); },
};
let res = input_to_u8 - 1;
Result::Ok(res)
}
function that returns errors to the calling code using a match
expression.
The code that calls this parse_u8 will handle getting either an Ok value
that contains a number or an Err value that contains an error message. It’s up
to the calling code to decide what to do with those values. If the calling code
gets an Err value, it could call panic! and crash the program, or use a
default value. We don’t have enough information on what the calling code is
actually trying to do, so we propagate all the success or error information
upward for it to handle appropriately.
This pattern of propagating errors is so common in Cairo that Cairo provides the
question mark operator ? to make this easier.
A Shortcut for Propagating Errors: the ? Operator
Listing 9-2 shows an implementation of mutate_byte that
has the same functionality as the one in Listing 9-1 but uses
the ? operator to gracefully handle errors.
// A hypothetical function that might fail
fn parse_u8(input: felt252) -> Result<u8, felt252> {
let input_u256: u256 = input.into();
if input_u256 < 256 {
Result::Ok(input.try_into().unwrap())
} else {
Result::Err('Invalid Integer')
}
}
fn mutate_byte(input: felt252) -> Result<u8, felt252> {
let input_to_u8: u8 = parse_u8(input)?;
let res = input_to_u8 - 1;
Ok(res)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_function_2() {
let number: felt252 = 258;
match mutate_byte(number) {
Ok(value) => println!("Result: {}", value),
Err(e) => println!("Error: {}", e),
}
}
}
9-2: A function that returns errors to the calling code
using the ? operator.
The ? placed after a Result value is defined to work in almost the same way
as the match expressions we defined to handle the Result values in
Listing 1. If the value of the Result is an Ok, the value inside the Ok
will get returned from this expression, and the program will continue. If the
value is an Err, the Err will be returned from the whole function as if we
had used the return keyword so the error value gets propagated to the calling
code.
In the context of Listing 2, the ? at the end of the parse_u8 call will
return the value inside an Ok to the variable input_to_u8. If an error
occurs, the ? operator will return early out of the whole function and give
any Err value to the calling code.
The ? operator eliminates a lot of boilerplate and makes this function’s
implementation simpler and more ergonomic.
Where The ? Operator Can Be Used
The ? operator can only be used in functions whose return type is compatible
with the value the ? is used on. This is because the ? operator is defined
to perform an early return of a value out of the function, in the same manner as
the match expression we defined in Listing 9-1. In Listing
9-1, the match was using a Result value, and the early
return arm returned an Err(e) value. The return type of the function has to be
a Result so that it’s compatible with this return.
In Listing 9-3, let’s look at the error
we’ll get if we use the ? operator in a function with a return type that is
incompatible with the type of the value we use ? on.
//TAG: does_not_compile
#[executable]
fn main() {
let some_num = parse_u8(258)?;
}
fn parse_u8(input: felt252) -> Result<u8, felt252> {
let input_u256: u256 = input.into();
if input_u256 < 256 {
Result::Ok(input.try_into().unwrap())
} else {
Result::Err('Invalid Integer')
}
}
9-3: Attempting to use the ? in a main
function that returns () won’t compile.
This code calls a function that might fail. The ? operator follows the
Result value returned by parse_u8, but this main function has the return
type of (), not Result. When we compile this code, we get an error message
similar to this:
$ scarb build
Compiling listing_invalid_qmark v0.1.0 (listings/ch09-error-handling/listing_invalid_qmark/Scarb.toml)
error: `?` can only be used in a function with `Option` or `Result` return type.
--> listings/ch09-error-handling/listing_invalid_qmark/src/lib.cairo:6:20
let some_num = parse_u8(258)?;
^^^^^^^^^^^^^^
warn[E0001]: Unused variable. Consider ignoring by prefixing with `_`.
--> listings/ch09-error-handling/listing_invalid_qmark/src/lib.cairo:6:9
let some_num = parse_u8(258)?;
^^^^^^^^
error: could not compile `listing_invalid_qmark` due to 1 previous error and 1 warning
This error points out that we’re only allowed to use the ? operator in a
function that returns Result or Option.
To fix the error, you have two choices. One choice is to change the return type
of your function to be compatible with the value you’re using the ? operator
on as long as you have no restrictions preventing that. The other choice is to
use a match to handle the Result<T, E> in whatever way is appropriate.
The error message also mentioned that ? can be used with Option<T> values as
well. As with using ? on Result, you can only use ? on Option in a
function that returns an Option. The behavior of the ? operator when called
on an Option<T> is similar to its behavior when called on a Result<T, E>: if
the value is None, the None will be returned early from the function at that
point. If the value is Some, the value inside the Some is the resultant
value of the expression, and the function continues.
Summary
We saw that recoverable errors can be handled in Cairo using the Result enum,
which has two variants: Ok and Err. The Result<T, E> enum is generic, with
types T and E representing the successful and error values, respectively.
The ResultTrait provides methods for working with Result<T, E>, such as
unwrapping values, checking if the result is Ok or Err, and panicking with
custom messages.
To handle recoverable errors, a function can return a Result type and use
pattern matching to handle the success or failure of an operation. The ?
operator can be used to implicitly handle errors by propagating the error or
unwrapping the successful value. This allows for more concise and clear error
handling, where the caller is responsible for managing errors raised by the
called function.