Returning results

Let's take a look at how you return results in Rust. Just to recap, in Rust, the way functions return values is a bit different from other languages. In Rust, the last expression in a function is the return value. This means that you don't need to use the return keyword to return a value from a function. The last expression in the function is automatically returned.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let result = add(1, 2);
    println!("The result is: {result}");
}

As seen in the previous example, the last expression in the add function is a + b, which is automatically returned from the function. This is a very convenient feature of Rust.

Note that there is no ; at the end of the a + b expression. This is because the ; would not return the result of the calculation a+b, but would return the unit type (). This is a common mistake for people coming from other languages. The () unit type is the only type that can be returned from a function that does not have a return type specified; in C this would be void.

If you combine this with what you've learned in the previous chapter, you can see that you can return the result of a match block, an if statement, or a loop block. Here's an example showing how to return the result of an if statement:

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        a
    } else {
        b
    }
}

fn main() {
    let result = max(1, 2);
    println!("{result} is the bigger number");
}

Rust also supports the return keyword. This is especially useful when you want to return early from a function. Here's an example:

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        return a;
    }
    b
}

fn main() {
    let result = max(1, 2);
    println!("{result} is the bigger number");
}

The last two examples are equivalent. The return keyword is often used to make the code more readable. And prevent nested if statements.

The Result type

So far, we have been returning "errors" as well as good results in the same way. This is not very useful, especially for downstream error handling.

In Rust, functions that have a positive and negative path typically return a Result type . The Result is an enumeration, holding the positive result in Ok() and the negative result (error) in Err().This is referred to as a "recoverable error" type.

Let's rewrite the previous example to introduce this concept.

fn age_group(age: i32) -> Result<String, String> {
    let valid_age = match age {
        _ if age < 0 => return Err("not born yet".to_string()),
        _ if age > 150 => return Err("seriously?!!".to_string()),
        validated => validated,
    };

    let result = match valid_age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 15;
    match age_group(age) {
        Ok(description) => println!("{}", description),
        Err(err) => println!("Error: {}", err),
    }
}

This example introduces a few new concepts. First we have the Result type itself. As you can see it is defined in this way: Result<T,E>, where T = the positive result type, and E = the error result type. There are also a few short-hands in the code to facilitate the use of Result:

  • Ok(...)
  • Err(...)

So when returning a Result we should use one of these shorthands to indicate if the Result is 'Ok' or an 'Err'. On the receiving end, we can use a match block to easily separate the Ok and Err responses.

There may be situations where an unrecoverable error occurs. These are situations that cannot be handled downstream and have only one remedy: stop execution.

You can use the panic! statement in these situations. It is used similar to the println! operation that we've used extensively so far.

fn age_group(age: i32) -> Result<String, String> {
    if age < 0 || age > 150 {
        panic!("age is out of range");
    }

    let result = match age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = -1;
    match age_group(age) {
        Ok(description) => println!("{}", description),
        Err(err) => println!("Error: {}", err),
    }
}

Check the output of the above example

Type alias

If you are using the Result<String, String> result-type throughout your code, it makes sense to create a type alias for this result combination. This is done with the type operator like this:

type AgeResult = Result<String, String>;

fn age_group(age: i32) -> AgeResult {
    if age < 0 || age > 150 {
        panic!("age is out of range");
    }

    let result = match age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 20;
    match age_group(age) {
        Ok(description) => println!("{}", description),
        Err(err) => println!("Error: {}", err),
    }
}

The ? operator

Rust provides a very convenient operator to test if an operation succeeded, if so, capture the positive result, if not, return out of the function with the error. This is especially useful when you are running a sequence of operations that could fail as part of a function. Like so:

fn too_young(age: i32) -> Result<i32, String> {
    if age < 0 {
        Err("too young".to_string())
    } else {
        Ok(age)
    }
}

fn too_old(age: i32) -> Result<i32, String> {
    if age > 150 {
        Err("too old".to_string())
    } else {
        Ok(age)
    }
}

fn check_age(age: i32) -> Result<i32, String> {
    let age = too_young(age)?;
    let age = too_old(age)?;
    Ok(age)
}

fn age_group(age: i32) -> Result<String, String> {
    let validated_age = check_age(age)?;

    let result = match validated_age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 200;
    match age_group(age) {
        Ok(description) => println!("{}", description),
        Err(err) => println!("Error: {}", err),
    }
}

The ? operator can be used as long as the error signatures of the functions match.

if let statement

If you are only interested in the positive result, you can create a conditional path to handle the positive case. Or in reverse, you can create a path that takes care of the error situation and let the positive case pass through. Here's an example:

fn age_group(age: i32) -> Result<String, String> {
    let valid_age = match age {
        _ if age < 0 => return Err("not born yet".to_string()),
        _ if age > 150 => return Err("seriously?!!".to_string()),
        validated => validated,
    };

    let result = match valid_age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 15;
    let age_result = age_group(age);

    if let Ok(description) = age_result {
        println!("{}", description);
    }
}

Now check what happens if you add the negative path as well:

fn age_group(age: i32) -> Result<String, String> {
    let valid_age = match age {
        _ if age < 0 => return Err("not born yet".to_string()),
        _ if age > 150 => return Err("seriously?!!".to_string()),
        validated => validated,
    };

    let result = match valid_age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 15;
    let age_result = age_group(age);

    if let Ok(description) = age_result {
        println!("{}", description);
    }

    if let Err(err) = age_result {
        println!("Error: {}", err);
    }
}

We see a familiar error:

error[E0382]: use of moved value: `age_result`
  --> src/main.rs:25:23
   |
21 |     if let Ok(description) = age_result {
   |               ----------- value moved here
...
25 |     if let Err(err) = age_result {
   |                       ^^^^^^^^^^ value used here after partial move
   |
   = note: move occurs because value has type `std::string::String`, which does not implement the `Copy` trait

What happened?

When we execute the if let statement, we are actually moving part of the Result into the description variable, which moves the ownership with it. This means that age_result is no longer available after the if let statement. Often you can borrow the Result in these situations:

fn age_group(age: i32) -> Result<String, String> {
    let valid_age = match age {
        _ if age < 0 => return Err("not born yet".to_string()),
        _ if age > 150 => return Err("seriously?!!".to_string()),
        validated => validated,
    };

    let result = match valid_age {
        _ if age < 10 => "child".to_string(),
        _ if age >= 18 => "adult".to_string(),
        a => format!("teenager of {} years old", a),
    };

    Ok(result)
}

fn main() {
    let age = 15;
    let age_result = age_group(age);

    if let Ok(description) = &age_result {
        println!("{}", description);
    }

    if let Err(err) = &age_result {
        println!("Error: {}", err);
    }
}

Reference material