Rust Enum Essentials: From Basics to Advanced Patterns

In this article, we will discover how Rust's Enums simplify complex code by handling multiple data types, enabling efficient pattern matching and error handling.

Rust Enum Essentials: From Basics to Advanced Patterns

An enum (short for enumeration) is a data type in Rust that represents multiple possible values, called variants. We define it using the enum keyword followed by the enum’s name. After that comes the {} braces, where we define the variants that the enum can have.

enum MyEum {
    VariantA,
    VariantB,
    VariantC,
}

fn main() {
    let me: MyEum = MyEum::VariantB;

    match me {
        MyEum::VariantA => println!("me is VariantA"),
        MyEum::VariantB => println!("me is VariantB"),
        MyEum::VariantC => println!("me is VariantC"),
    }
}
$ cargo run
me is VariantB

In the program above, MyEnum is an enum that has 3 variants: VariantA, VariantB, and VariantC. We can name these variants however we like. The general naming convention for both the enum name and its variants is PascalCase. Here, the variable me has the MyEnum type, which means it contains one of the variants of MyEnum. To check which variant it contains, we use the match statement, where each arm checks for the existence of a specific variant. We access the variant of an enum using the <enum>::<variant> expression.

As we discussed in the Control Flow lesson, when we use the match statement, all arms together must collectively match every possible value of the type. In the case above, the match statement matches all possible variants of the enum MyEnum. We can also use the _ (catch-all pattern) to match any remaining values not explicitly covered by the match arms, as shown below.

enum MyEum {
    VariantA,
    VariantB,
    VariantC,
}

fn main() {
    let me: MyEum = MyEum::VariantB;

    match me {
        MyEum::VariantA => println!("me is VariantA"),
        _ => println!("me is other variant"),
    }
}
$ cargo run
me is other variant

C-like Enums

The enum we declared above is a C-like enum, which is also declared in a similar way in many other languages, including TypeScript and Java. Rust automatically assigns integer values to each variant (also called discriminants), starting from 0.

enum MyEum {
    VariantA, // 0
    VariantB, // 1
    VariantC, // 2
}

fn main() {
    let me: MyEum = MyEum::VariantB;

    println!("me value: {}", me as u8);
}

// me value: 1

If you want to see the discriminant value held by an enum value, like me in the example above, you need to cast it as an integer. Normally, Rust uses the smallest possible memory size to store an enum, such as u8, which can represent up to 256 enum variants. If we have more variants, Rust will automatically use larger memory types like u16, u32, or more.

use std::mem;

enum MyEum {
    VariantA, // 0
    VariantB, // 1
    VariantC, // 2
}

#[repr(u64)]
enum MyBigEnum {
    VariantA, // 0
    VariantB, // 1
    VariantC, // 2
}

fn main() {
    println!("Size of MyEum: {} bytes", mem::size_of::<MyEum>());
    println!("Size of MyBigEnum: {} bytes", mem::size_of::<MyBigEum>());
}
$ cargo run
Size of MyEum: 1 bytes
Size of MyBigEnum: 8 bytes

If you want to check the size occupied by a type in Rust, you can use the size_of() function in the mem module of Rust’s standard library std. Since this is a generic function that requires the type to be provided when calling, we use the size_of::<T>() syntax, where T is the type whose size needs to be determined. This function returns the size taken by a type in bytes. In the example above, the MyEnum enum takes only 1 byte (u8), as we would expect. However, if you want to specify the size of the enum discriminants explicitly, you can enforce that using the #[repr()] attribute, which is used to specify the memory layout of types.The MyBigEnum discriminant will now use 64 bits, or 8 bytes, to store the discriminant value.

use std::mem;

enum MyEum {
    VariantA = 100,
    VariantB = 200,
    VariantC = 300,
}

fn main() {
    let me = MyEum::VariantB;

    match me {
        MyEum::VariantA => println!("A: {}", me as u32),
        MyEum::VariantB => println!("B: {}", me as u32),
        MyEum::VariantC => println!("C: {}", me as u32),
    }

    println!("Size of MyEum: {} bytes", mem::size_of::<MyEum>());
}
$ cargo run
B: 200
Size of MyEum: 2 bytes

As you can see in the example above, we can also provide our own discriminant value for each enum variant. In this case, depending on the size of the maximum discriminant value, Rust will allocate an appropriate size for the enum, which is 2 bytes in the above example. We can still use the #[repr] attribute to override this default behavior, but we should be cautious about potential integer overflows, which could lead to undefined results.

const fn get_val(case: char) -> isize {
    match case {
        'A' => 100,
        'B' => 200,
        _ => 300,
    }
}

enum MyEum {
    VariantA = get_val('A'), // 100
    VariantB = get_val('B'), // 200
    VariantC = get_val('C'), // 300
}

Enum discriminant values must be compile-time constants. These can be either values or expressions that are evaluated at compile time. Unlike regular functions, which are evaluated at runtime, const functions are evaluated at compile time, which is why we can use them to provide discriminant values as shown above. The const function must return an isize to provide this value.

Associated data

Unlike a discriminant, where each enum variant gets a numerical value to distinguish itself from the other variants of the enum, an enum variant can additionally carry data, such as numbers, strings, structs, and more.

use std::mem;

#[derive(Debug)]
enum MyEnum {
    VariantA,                               // 0: no data
    VariantB(u8),                           // 1: tuple
    VariantC(u8, u8),                       // 2: tuple
    VariantD { name: String, age: u8 },     // 3: struct
}

fn main() {
    let me = MyEnum::VariantD {
        name: "John Doe".to_string(),
        age: 21,
    };

    println!("me: {:?}", me);
    println!("Discriminant of me: {:?}", mem::discriminant(&me));
    println!("Size of MyEum: {} bytes", mem::size_of::<MyEnum>());
}
$ cargo run
me: VariantD { name: "John Doe", age: 21 }
Discriminant of me: Discriminant(3)
Size of MyEum: 32 bytes

In this case, the MyEnum enum has variants that contain additional data. VariantA doesn’t contain any data. The VariantB and VariantC variants contain tuple data with 1 and 2 elements, respectively. VariantD contains struct data with fields name and age.

Unlike a normal tuple declaration, the tuple syntax with just one element in an enum variant doesn’t require a trailing comma, such as let t: (u8,) = (1,);.

Though enum variants can have associated data, that doesn’t negate the requirement for a discriminant. The variants will still get discriminants starting from 0. However, to view the discriminants, we would need to use the mem::discriminant() method. In the example above, the discriminant of VariantD is 3. The size of the enum will be equal to the maximum size of the associated data plus the size of the discriminant.

fn main() {
    let me = MyEnum::VariantD {
        name: "John Doe".to_string(),
        age: 21,
    };

    match me {
        MyEnum::VariantA => println!("[VariantA]"),
        MyEnum::VariantB(a) => println!("[VariantB] {}", a),
        MyEnum::VariantC(a, b) => println!("[VariantC] {},{}", a, b),
        MyEnum::VariantD { name, age } => println!("[VariantD] name: {}, age: {}", name, age),
    }
}
$ cargo run
[VariantD] name: John Doe, age: 21

When enum variants have associated data, we can use pattern matching within a match statement to extract that data.

Pattern matching

In the example above, we saw how using the match statement, we can not only check which variant of an enum a variable holds but also extract the associated data from that variant. This is possible because of Rust’s powerful pattern matching capabilities. Let’s explore how pattern matching can solve both simple and complex problems, one at a time.

Unit Variant (no associated data)

enum MyEnum {
    VariantA,
    VariantB,
}

fn main() {
    let me = MyEnum::VariantB;

    match me {
        MyEnum::VariantA => println!("[VariantA]"),
        MyEnum::VariantB => println!("[VariantB]"),
    }
}
$ cargo run
[VariantB]

A unit variant has no associated data. Whether they have automatically or manually assigned discriminant values doesn’t affect how they will be matched against a pattern. The pattern to match them is very simple: we just specify the fully qualified variant name in the pattern section of the match arm as we did above. Our MyEnum enum contains all variants without any associated data.

Tuple-like variants

enum MyEnum {
    VariantA(u8),
    VariantB(u8, u8),
}

fn main() {
    let me = MyEnum::VariantB(1, 2);

    match me {
        MyEnum::VariantA(a) => println!("[VariantA] a: {}", a),
        MyEnum::VariantB(a, b) => println!("[VariantB] a: {}, b: {}", a, b),
    }
}
$ cargo run
[VariantB] a: 1, b: 2

In the case of tuple-like variants, the pattern we use closely resembles the variant’s syntax, but with the types replaced by labels (variable names) to capture the value(s) in the associated data.

fn main() {
    let me = MyEnum::VariantA(0);

    match me {
        MyEnum::VariantA(0) => println!("[VariantA] Zero Case"),
        MyEnum::VariantA(a) => println!("[VariantA] a: {}", a),
        MyEnum::VariantB(a, b) => println!("[VariantB] a: {}, b: {}", a, b),
    }
}
$ cargo run
[VariantA] Zero Case

We can also provide multiple arms of the match statement that use the same variant; however, the patterns should ideally always be mutually exclusive. In the above example, the first arm will only match when VariantA has a value of 0. If it doesn’t match, the next arm will always match for VariantA.

fn main() {
    match me {
        MyEnum::VariantA(0) => println!("[VariantA] Zero Case"),
        MyEnum::VariantA(a) if a % 2 == 0 => println!("[VariantA] Even: {}", a),
        MyEnum::VariantA(a @ 10..20) => println!("[VariantA] Between 10-20: {}", a),
        MyEnum::VariantA(_) => println!("[VariantA] Other"),
        MyEnum::VariantB(a, b) => println!("[VariantB] a: {}, b: {}", a, b),
    }
}

In the above example, we have used some pattern matching techniques such as using the match guards using the if expression as well as binding a value to a label using the @ operator. If we want to ignore the associated data, we can use the _ wildcard pattern. If we replace the value x in let me = MyEnum::VariantA(x);, we will get the following result:

  • 0: [VariantA] Zero Case
  • 1: [VariantA] Other
  • 2: [VariantA] Even: 2
  • 12: [VariantA] Even: 12
  • 13: [VariantA] Between 10-20: 13
fn main() {
    match me {
        MyEnum::VariantA(a) => println!("[VariantA] a: {}", a),
        MyEnum::VariantB(a, b) if a == b => println!("[VariantB] a == b: {}", a),
        MyEnum::VariantB(0, b) => println!("[VariantB] a: 0, b: {}", b),
        MyEnum::VariantB(a, 0) => println!("[VariantB] a: {}, b: 0", a),
        MyEnum::VariantB(a, b) => println!("[VariantB] a: {}, b: {}", a, b),
    }
}

Similarly, tuple-like variants with two or more values can also use these pattern matching techniques as shown above. For let me = MyEnum::VariantB(x, y);, where x and y are replaced with some integers, it would behave as follows:

  • (0,0): [VariantB] a == b: 0
  • (0,1): [VariantB] a: 0, b: 1
  • (1,0): [VariantB] a: 1, b: 0
  • (0,0): [VariantB] a == b: 0
  • (1,2): [VariantB] a: 1, b: 2
match value {
    <pattern1> | <pattern2> | <pattern3> => {
        // one action for all matching patterns
    },
    _ => {
        // action for non-matching patterns
    },
}

With the match statement, as we learned from the “Control Flow” lesson, we can use the | (pipe) operator to match multiple patterns in a single match arm and perform a common action. In the program below, when either of the values in the tuple variant VariantB is 0, we perform a common action for it.

enum MyEnum {
    VariantA(u8),
    VariantB(u8, u8),
}

fn main() {
    let me = MyEnum::VariantB(0, 1);

    match me {
        MyEnum::VariantA(a) => println!("[VariantA] a: {}", a),
        MyEnum::VariantB(0, x) | MyEnum::VariantB(x, 0) => {
            println!("[VariantB] x: {}", x);
        }
        MyEnum::VariantB(a, b) => println!("[VariantB] a: {}, b: {}", a, b),
    }
}

// [VariantB] x: 1

Struct-like variants

enum MyEnum {
    VariantA { id: u8, age: u8, member: bool },
    VariantB(u8),
}

fn main() {
    let me = MyEnum::VariantA {
        id: 100,
        age: 21,
        member: true,
    };

    match me {
        MyEnum::VariantA { id: 0, .. } => {
            println!("[VariantA] [ZERO MEMBER]")
        }
        MyEnum::VariantA { id, .. } if id == 1 => {
            println!("[VariantA] [ONE MEMBER] id: {}", id)
        }
        MyEnum::VariantA { id: id @ 1..10, .. } => {
            println!("[VariantA] [1..10 MEMBER] id: {}", id)
        }
        MyEnum::VariantA { id, age, member } => {
            println!("[VariantA] id: {}, age: {}, member: {}", id, age, member)
        }
        MyEnum::VariantB(a) => println!("[VariantB] a: {}", a),
    }
}
$ cargo run
[VariantA] id: 100, age: 21, member: true

A struct-like variant is declared with the variant name followed by the struct fields, just like how we would declare a normal struct. While destructuring it in the match arm, we simply list the field names after the variant name. We can use the .. rest pattern to match fields that are not specified to always match for all values. Like tuple variants, we can also use match guards and @ bindings here as well.

In the example above, the VariantA of the MyEnum enum is a struct-like variant while VariantB is a tuple-like variant. Since we want me to be MyEnum and have VariantA variant, we initialized it like a normal struct and provided values for its fields but used MyEnum::VariantA as struct name.

enum MyBigNameEnum {
    VariantA,
    VariantB(u8),
    VariantC(u8, u8),
    VariantD { id: u8, age: u8 },
}

fn main() {
    let me = MyBigNameEnum::VariantA;

    use MyBigNameEnum::*;

    match me {
        VariantA => println!("[VariantA]"),
        VariantB(..) => println!("[VariantB]"),
        VariantC(..) => println!("[VariantC]"),
        VariantD { .. } => println!("[VariantD]"),
    }
}

When we have large enum names, repeating them every time in the match arms can become tedious and unmaintainable, especially when we have many variants in the enum. Instead of using EnumName::VariantName =>, we can use just VariantName => by bringing all enum variants into the current scope, as we did above using the use EnumName::*; statement. However, this can lead to issues like namespace pollution and reduced code readability. A better alternative is to use the use <> as <> syntax to rename the enum for more concise usage.

use MyBigNameEnum as ME;

match me {
    ME::VariantA => println!("[VariantA]"),
    ME::VariantB(..) => println!("[VariantB]"),
    ME::VariantC(..) => println!("[VariantC]"),
    ME::VariantD { .. } => println!("[VariantD]"),
}

if let statement

enum MyEnum {
    VariantA,
    VariantB(u8),
    VariantC(u8, u8),
}

fn main() {
    let me: MyEnum = MyEnum::VariantB(1);

    match me {
        MyEnum::VariantB(a) => println!("[VariantB] a: {}", a),
        _ => {
            // do nothing
        }
    }
}

// [VariantB] a: 1

Let’s take a look at the program above. We have a simple enum MyEnum with a few variants. We are only interested in checking if me, which holds one of the variants of the enum MyEnum, is VariantB. We could use the match statement, which checks if the value is VariantB and uses _ to match the rest. However, there is a more concise way to do this by using the if let statement.

if let <pattern> = <expr> {
    // action
} else {
    // action
}

The pattern is the syntax we use for pattern matching, and expr is the enum value or an expression that evaluates to an enum. We can optionally use the else block when the pattern is not matched.

enum MyEnum {
    VariantA,
    VariantB(u8),
    VariantC(u8, u8),
}

fn main() {
    let me = MyEnum::VariantB(1);

    if let MyEnum::VariantB(a) = me {
        println!("[VariantB] a: {}", a);
    }
}

// [VariantB] a: 1

In the modified example above, our pattern is MyEnum::VariantB(a), which matches when me contains the variant VariantB with any value of the associated u8 data. We can also use the @ binding with if let, such as if let MyEnum::VariantB(a @ 1..10) =, but pattern guards (additional if expression in the pattern) are not yet supported in if let.

Unlike match, which can be used both as a statement and as an expression that evaluates to a value, if let, let else, and while let are purely statements and cannot be used as expressions.

matches!() macro

enum MyEnum {
    VariantA,
    VariantB(u8),
    VariantC(u8, u8),
}

fn main() {
    let me = MyEnum::VariantB(1);

    let it_matches = matches!(me, MyEnum::VariantB(1));
    println!("it_matches: {}", it_matches);
}

// it_matches: true

If we are only interested in checking whether a pattern matches, we can use the matches!(expr, pattern) macro. This macro comes from the std library and is part of the prelude, so we don’t need to import it manually. It checks if expr, which is an enum or an expression that evaluates to an enum, matches pattern, and it evaluates to a bool. Unlike match or if let, this macro is intended solely for checking if a pattern matches and cannot bind associated data to variables.

We can use a pattern guard and a range expression (but without @ binding) with matches!().

let else statement

The let else statement is used to perform an action that breaks the control flow when a pattern doesn’t match.

let <pattern> = <expr> else {
    // breaking action
}

The syntax of the let else statement looks like the example above. The pattern is what we use to match the enum value, and expr is the enum value or an expression that evaluates to an enum. Unlike the else block in an if let statement, the else block of a let else statement must return the ! (never) type. Therefore, a breaking action must occur inside it, such as break, return, or panic.

enum MyEnum {
    VariantA,
    VariantB(u8),
    VariantC(u8, u8),
}

fn main() {
    let me = MyEnum::VariantB(1);

    let MyEnum::VariantB(a) = me else {
        panic!("Expected VariantB, found something else.");
    };

    println!("[VariantB] a: {}", a);
}

// [VariantB] a: 1

The let else block is primarily used for handling errors or returning early from a function. In the example above, we want me to be the VariantB of MyEnum; otherwise, it’s acceptable for our program to crash at runtime. Another key difference between the if let and let else statements is that variables in the pattern of the let else are bound to the outer scope, which is why we can use them even after the let else block.

while let loop

while let <pattern> = <expr> {
    // action
}

The while let loop works similarly to the while loop, but instead of checking a bool value, it checks if a pattern matches an expr, which could be an enum value or an expression that evaluates to an enum.

fn main() {
    let mut v: Vec<(u8, u8)> = vec![(1, 1), (2, 2)];

    while let Some((a, b)) = v.pop() {
        println!("Popped ({},{})", a, b);
    }
}
$ cargo run
Popped (2,2)
Popped (1,1)

The .pop() method on a Vec removes the last element from the vector and returns it. It returns the element wrapped in the Some variant of the Option enum. If the vector is empty and .pop() is called, it returns the None variant of the Option enum. The while let loop will continue to loop as long as the pattern matches. In this case, the pattern will match as long as the vector v has elements.

Option Enum

Unlike some programming languages such as Java or JavaScript, Rust does not have a notion of a null or undefined value. All values must always be defined according to their data types. However, sometimes we need to represent the concept of data being temporarily unavailable. For example, a file may not have been completely read yet, so the file variable is empty at the moment but will contain the file data at some point in the future. Since Rust doesn’t have a built-in solution like null, we use a facade to handle this, and that’s where the Option enum comes in.

enum Option<T> {
    None,
    Some(T),
}

The Option enum has two variants: Some and None. The None variant doesn’t contain any associated data, while Some has tuple-like associated data with a single element of type T. Since Option<T> is generic, we can specify at the implementation site which data type Some will carry. As this is a common use case, Rust provides this enum for us and includes it in the standard library (std). Additionally, it is part of Rust’s prelude, along with its variants Some and None, meaning we don’t need to access these using Option::Some and Option::None; we can access them directly.

fn main() {
    let mut file_size: Option<f32> = None;

    if let Some(size) = file_size {
        println!("[1] File size is {} MB.", size)
    } else {
        println!("[1] File hasn't been read yet.")
    }

    file_size = Some(12.5);

    if let Some(size) = file_size {
        println!("[2] File size is {} MB.", size)
    } else {
        println!("[2] File hasn't been read yet.")
    }
}
$ cargo run
[1] File hasn't been read yet.
[2] File size is 12.5 MB.

In the above example, we declared a variable file_size that can hold data of type Option<f32>. Since it can be either the Some(f32) or None variant, we initially assigned it the value of None. Since file_size is an enum, we can use match, if let, and other pattern matching techniques with it. Given that Option has only two variants, if let else is a good use case for handling it. Since file_size is initially None, the else block of the first if let else statement is executed. Later, we override its value with Some(12.5), so the if block of the second if let statement is executed.

Rust can infer the type of the Option enum if the initial value is Some(T), since Some is the only variant that carries type information. However, in the example above, we provided an explicit type for file_size because the initial value is None.

fn main() {
    let file_size: Option<f32> = Some(12.5);

    if file_size.is_some() {
        println!("File has been read.");
    }
}

// File has been read.

If you are only interested in whether an Option value is Some or None without needing the data that Some carries, you can use the .is_some() and .is_none() methods. There are other useful methods on Option as well. You can explore them here.

fn main() {
    let a: Option<u32> = None;
    let a_sqr: Option<u32> = a.map(|v| v * v);
    println!("a_sqr: {:?}", a_sqr);

    let b: Option<u32> = Some(3);
    let b_sqr: Option<u32> = b.map(|v| v * v);
    println!("b_sqr: {:?}", b_sqr);
}
$ cargo run
a_sqr: None
b_sqr: Some(9)

One of the most important methods is .map(). It takes a closure and returns a new Option enum. If the original variant is None, it returns None. However, if it is Some, the closure is called with the contained value, and the result is wrapped in a new Some value. As we can see in the example above, a is None, so .map() did not execute the closure and returned None. However, in the case of b, the closure was called with the associated value and returned a new Some with the transformed value.

fn main() {
    let message: Option<String> = Some("Hello World".to_string());

    // text type: `String`
    if let Some(text) = message {
        println!("Message text: {}", text);
    }

    println!("message: {:?}", message); // <-- error here
}

When we run the program above, we get the following error message:

$ cargo run
error[E0382]: borrow of partially moved value: `message`
 --> src/main.rs:8:31
  |
4 |     if let Some(text) = message {
  |                 ---- value partially moved here
...
8 |     println!("message: {:?}", message);
  |                               ^^^^^^^ value borrowed here after partial move

This error occurs because, in the if let Some(text) = message statement, we are accessing the String value contained in the Some variant. Since String does not implement the Copy trait (we will discuss this in the Traits and Ownership lessons), the value is moved from message into the if let scope. Therefore, when we try to use message again after the if let statement, we get the error value partially moved here because the String value has already been moved out of message.

fn main() {
    let message: Option<String> = Some("Hello World".to_string());

    // text type: `&String`
    if let Some(ref text) = message {
        println!("Message text: {}", text);
    }

    println!("message: {:?}", message);
}
$ cargo run
Message text: Hello World
message: Some("Hello World")

The ref keyword is used in pattern matching to bind a value as a reference, preventing the value from being moved. In the above example, instead of taking ownership of text, text would be a reference to the original String value contained in the Some variant of message. Since this prevents the String from being moved, we can continue using message.

In addition to the ref keyword, the Option enum (as well as the Result enum, which we will discuss next) has the .as_ref() method, which converts an Option<T> into an Option<&T>. This way, instead of consuming the original enum, we create a new enum that references the data contained in the original enum through its Some variant, as shown below. Here, the redeclaration of the message variable is an example of shadowing, which we covered in the variables lesson.

fn main() {
    let message: Option<String> = Some("Hello World".to_string());
    let message: Option<&String> = message.as_ref();

    // text type: `&String`
    if let Some(text) = message {
        println!("Message text: {}", text);
    }

    println!("message: {:?}", message);
}
$ cargo run
Message text: Hello World
message: Some("Hello World")

Result Enum

Unlike programming languages like JavaScript or Java, Rust doesn’t have a try/catch construct to handle errors. In these languages, when an error (an exception) occurs, it is propagated up the stack until a catch block catches and handles it. These are recoverable errors, meaning that when an error occurs, the program doesn’t necessarily need to terminate. Rust uses two mechanisms for error handling: recoverable and unrecoverable errors.

An unrecoverable error, called a panic, causes the program to terminate when it occurs. We will discuss this further in the Error Handling lesson. Recoverable errors, on the other hand, are managed through the Result enum. Unlike other languages, Rust handles recoverable errors as values. For example, when a function encounters an error, instead of using try/catch, the error is returned as a value from the function and must be checked manually. When a function is expected to return an error, we use the Result enum as the return type.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

When a value is expected to potentially contain an error (such as a function’s return value), we use the Result enum type. The Ok(T) variant holds data of type T when there is no error, while the Err(E) variant carries error information of type E. Like the Option enum, the Result enum is also part of the standard library (std) and is included in the prelude, along with its variants.

fn read_file(retry_count: u8) -> Result<f32, String> {
    if retry_count == 0 {
        Err(String::from("Something went wrong, try again."))
    } else {
        Ok(12.5)
    }
}

fn main() {
    let mut file_size: Result<f32, String> = read_file(0);

    match file_size {
        Ok(size) => println!("[1] File size is {} MB.", size),
        Err(err_message) => println!("[1] Error: {}", err_message),
    }

    file_size = read_file(1);

    match file_size {
        Ok(size) => println!("[2] File size is {} MB.", size),
        Err(err_message) => println!("[2] Error: {}", err_message),
    }
}
$ cargo run
[1] Error: Something went wrong, try again.
[2] File size is 12.5 MB.

In the above example, the file_size variable contains a value of type Result<f32, String>. Although we don’t need to explicitly specify this type here because the function’s return type clearly indicates it, there are cases where an initial value (whether Ok or Err) needs to be provided. In such cases, the type must be specified because neither Ok nor Err alone carries both the data and the error information.

The program is straightforward. We have a function read_file that returns Result<f32, String>, meaning it can return either Ok(f32) or Err(String), or in simpler terms, data of type f32 or an error of type String. We can use either if let or match to check which variant is returned.

fn main() {
    let file_size: Result<f32, String> = Ok(12.5);

    let size: f32 = file_size.unwrap();
    println!("size: {}", size);
}

When looking at Rust code, you will often come across instances where the .unwrap() method is called on either an Option or Result enum. This method, when called on a Result enum, returns the associated data from the Ok variant. In the example above, since file_size contains an f32 within its Ok variant, the size is of type f32. However, if file_size is an Err variant, the program will panic with the contained error message. We should only use this method when we are certain that the enum value is always Ok, or when a panic will not cause significant issues in our project.

In the case of the Option enum, the .unwrap() method will extract the associated data from the Some variant. If the enum value is None, it will cause a panic, just like when a Result value contains Err.

fn main() {
    let file_size: Result<f32, String> = Err("An error occurred.".to_string());
    println!("[unwrap_or]: {:?}", file_size.unwrap_or(0.0));

    let file_size: Result<f32, String> = Err("An error occurred.".to_string());
    println!("[unwrap_or_else]: {:?}", file_size.unwrap_or_else(|err| 0.0));

    let file_size: Result<f32, String> = Err("An error occurred.".to_string());
    println!("[unwrap_or_default]: {:?}", file_size.unwrap_or_default());
}
$ cargo run
[unwrap_or]: 0.0
[unwrap_or_else]: 0.0
[unwrap_or_default]: 0.0

A safer alternative to .unwrap() is to use one of the following methods on Option or Result:

  • unwrap_or: This method allows us to provide a fallback value when the enum value is None or Err.
  • unwrap_or_default: This method returns the default value of the type (if it implements Default trait) when the enum value is None or Err.
  • unwrap_or_else: This method takes a closure to generate a fallback value when the enum value is None or Err.

Since Option and Result are common in our day-to-day Rust programming, we sometimes need to convert between them. For example, if a function returns a Result and we want to return an Err when some operation returns None, it should be easy to convert the None into an Err. Fortunately, Rust provides convenient methods on the Option and Result enums to handle these conversions.

fn main() {
    let opt: Option<f32> = Some(12.5);
    let opt_res: Result<f32, String> = opt.ok_or("Error message".to_string());
    println!("opt_res: {:?}", opt_res);

    let res: Result<f32, &'static str> = Ok(12.5);
    let res_opt: Option<f32> = res.ok();
    println!("res_opt: {:?}", res_opt);
}
$ cargo run
opt_res: Ok(12.5)
res_opt: Some(12.5)

In the above program, we have an opt variable which is an Option. We can convert it to a Result such that Some(T) becomes Ok(T) and None becomes an Err(E) using the .ok_or() method, which takes the error value of type E to be wrapped in Err(E). We can also use the .ok_or_else() method, which, instead of taking a raw value for Err, accepts a closure that returns the error value. Conversely, if we have a res variable, which is a Result, and we want to convert it to an Option, we can call the .ok() method on the Result, which converts Ok(T) to Some(T) and Err(E) to None.

? operator

The ? operator (also called the question mark operator or try operator) can be very useful while working with the Option and, especially, the Result type. If we have a function that returns Result<T, E>, and inside that function, we are calling another function that also returns a Result<T, E>, then instead of explicitly handling the error, we can simply propagate it using the ? operator. Let’s see what I mean by this.

fn read_file() -> Result<f32, String> {
    Err("Failed to read file.".to_string())
}

fn read_log_file() -> Result<f32, String> {
    let file: Result<f32, String> = read_file();

    match file {
        Ok(size) => {
            println!("Log file size is {} MB", size);
            Ok(size)
        }
        Err(err) => Err(err),
    }
}

fn main() {
    let log_file: Result<f32, String> = read_log_file();
    println!("log_file: {:?}", log_file);
}
$ cargo run
log_file: Err("Failed to read file.")

In the example above, the read_file function returns a Result<f32, String>. This function is called inside the read_log_file function, which also returns a Result<f32, String>. Inside this function, we are using a match expression to check whether the read_file function returns an Ok or an Err. If Ok is received, we log a message with data inside Ok and returns it; otherwise, we simply return the Err. Here, we are only interested in the Ok value, and if there is an error, we want to propagate it. This is where the ? operator becomes very useful.

fn read_log_file() -> Result<f32, String> {
    let size: f32 = read_file()?;

    println!("Log file size is {} MB", size);
    Ok(size)
}

Above, we modified the read_log_file function and used the ? as a postfix operator after the read_file() function call. When we place the ? operator after a value or an expression that evaluates to a Result enum, it unwraps the value, much like the .unwrap() function does if the enum variant is Ok; otherwise, it returns the Err variant immediately. You can place this operator almost anywhere in the function body, and it will work as expected.

fn read_file() -> Option<f32> {
    None
}

fn read_log_file() -> Result<f32, String> {
    let size: f32 = read_file().ok_or("Failed to read file.".to_string())?;

    println!("Log file size is {} MB", size);
    Ok(size)
}

fn main() {
    let log_file: Result<f32, String> = read_log_file();
    println!("log_file: {:?}", log_file);
}

As we learned, using the .ok() and .ok_or() methods, we can convert between Option and Result interchangeably. This is especially beneficial when using the ? operator. For example, in the program above, the read_file function returns an Option. Inside the read_log_file function, we can simply use the .ok_or() method, which converts its return value to a Result, and the ? postfix operator unwraps the Ok value while returning the Err if it occurs.

struct FileError {
    text: String,
}

fn read_file() -> Result<f32, FileError> {
    Err(FileError {
        text: "Failed to read file.".to_string(),
    })
}

fn read_log_file() -> Result<f32, String> {
    let size: f32 = read_file()?; // <-- error here

    println!("Log file size is {} MB", size);
    Ok(size)
}

fn main() {
    let log_file: Result<f32, String> = read_log_file();
    println!("log_file: {:?}", log_file);
}

In the above example, the read_file method returns a Result with a FileError struct as the associated data for the Err variant. In the read_log_file function, since we are using the ? operator with the read_file() call, it will return Err(FileError), which is not compatible with the Err(String) that the read_log_file function expects. Hence, when we run this program, we get the following compilation error:

$ cargo run
error[E0277]: `?` couldn't convert the error to `String`
  --> src/main.rs:18:32
   |
17 | fn read_log_file() -> Result<f32, String> {
   |                       ------------------- expected `String` because of this
18 |     let size: f32 = read_file()?;
   |                     -----------^ the trait `From<FileError>` is not implemented for `String`, which is required by `Result<f32, String>: FromResidual<Result<Infallible, FileError>>`
   |                     |
   |                     this can't be annotated with `?` because it has type `Result<_, FileError>`

Rust can handle the conversion from FileError to String if FileError implements the From trait, as the error message clearly suggests. The From trait requires a type to implement the from method, which contains the logic for this conversion.

struct FileError {
    text: String,
}

impl From<FileError> for String {
    fn from(value: FileError) -> Self {
        value.text.clone()
    }
}

// --snip--

Here, we have implemented the From trait for the String type that converts from FileError to String. The from method for this implementation returns a copied String value contained in the text field of the FileError struct. With this implementation, Rust can now convert from Err(FileError) to Err(String).

In the case of Option, the ? operator returns the None value. So far, the ? operator only works on the Option and Result enums because they implement the Try trait. If you have a custom type and also want it to use the ? operator, then your type must implement the Try trait.

main() with Result

fn main() -> () {
    println!("Hello, world!");

    () // same as `return ();`
}

As we learned, main() is a special function in Rust that marks the entry point of the execution of our Rust binary. Also, as we discussed in the functions lesson, since it doesn’t explicitly return anything, it automatically returns (). We can also explicitly specify the return type as () as shown above. However, () is not the only value the main function can return. Any value that implements the Termination trait can be returned from the main function.

This trait enforces a type to implement the report method, which returns an ExitCode. This exist code is then passed back to the operating system. On Unix-based systems, this is typically an integer where 0 means success, and non-zero values represent various error conditions. We can visualize the main function like this:

fn main() -> impl Termination {
    // --snip--
}

The following are some common types that implement the Termination trait:

  • ! (never type)
  • () (unit type)
  • ExitCode struct
  • impl<T: Termination, E: Debug> Termination for Result<T, E>: Type T should implement the Termination and E should implement the Debug trait

This means that we can return these types from the main function.

If the main function returns (), the exit code returned to the operating system is 0.

fn main() -> () {
    println!("Hello, world!");
}
$ cargo run
Hello, world!

$ echo $?
0

The $? expression in Bash returns the exit code of the previous command.

The ! (never type) means that the function never returns. This can happen if a panic occurs, the code enters an infinite loop, or the program exits manually using the std::process::exit() function. In these cases, the exit code depends on how the panic occurred or what exit code the exit() function was called with.

use std::process::ExitCode;

fn main() -> ExitCode {
    ExitCode::from(2)
}
$ cargo run
Hello, world!

$ echo $?
2

If you want to provide a custom exit code, you can use the ExitCode struct as shown above. However, since 0 and 1 are the standard exit codes used, you can also use the ExitCode::SUCCESS and ExitCode::FAILURE constants.

Most of the time, not only is the exit code important, but it’s also crucial to visibly show the user why the program exited with an error. For that, you can return a Result whose Err variant must implement the Debug trait. If the main function returns Err, this debug information is directed to STDERR.

#[derive(Debug)]
struct MyErr {
    code: &'static str,
}

fn main() -> Result<(), MyErr> {
    Err(MyErr {
        code: "PARSING_ERROR",
    })
}
$ cargo run
Error: MyErr { code: "PARSING_ERROR" }

$ echo $?
1

In the program above, we have a MyErr struct, and we have derived the Debug trait on it, which implements the fmt method to print default debug information for it. When we return the Err variant with the MyErr value from the main function, Rust outputs this debug information to STDERR, as we can see above.

We can also use the eprintln! macro, which works exactly like println! but outputs the formatted data to STDERR instead of STDOUT. We will learn more about this in the “Error Handling” lesson.

#rust #enum #pattern-matching