Get Started with Rust: Enums

There are two ways to create custom data types in Rust: structs and enums.

In contrast to structs, enums construct a type that has multiple variants and not multiple fields.

While structs are a part of pretty much any programming language, enums are not so mainstream and are mostly seen in statically typed functional languages like Haskell or OCaml. But, as we’ll see, they are quite nice for domain modeling with types.

This article will show you how to use enums in Rust. By the end of the article, you’ll know the answers to these questions:

  • How to define and instantiate an enum?
  • When to use enums instead of structs?
  • What is pattern matching, and why is it so useful?
  • What are the Option and Result enums, and how to use them?

What is an enum in Rust?

Enums (short for enumerations) are a way to create compound data types in Rust. They let us enumerate multiple possible variants of a type.

For example, we can use enums to recreate a Bool data type with two variants: True and False.

//   [1]  
enum Bool {
//  [2]
    True,
    False,
}

// [1]: The name of the data type.
// [2]: The variants of the data type. 

We can get a value of this type by constructing it with one of the two variants.

let is_true = Bool::True;
let is_false = Bool::False;

Enum variants are like structs: they can contain fields, which can be unnamed or named. In the example below, the Alive variant contains an unnamed signed 8-bit integer.

enum HealthBar {
    Alive(i8),
    Dead,
}

let is_alive = HealthBar::Alive(100);
let is_dead = HealthBar::Dead;

If we would like to signify that the field represents life points, we can use a named “life” field instead:

enum HealthBar {
    Alive{life: i8},
    Dead,
}

let is_alive = HealthBar::Alive{life: 100};
let is_dead = HealthBar::Dead;

Rust enums might be familiar to you from other languages in the guise of sum types (Haskell and other FP languages) or discriminated unions. Together with structs, they create a simple yet highly usable mechanism for encoding a lot of information in your types.

Enums vs. structs

A struct has multiple fields. In contrast, an enum has multiple variants.

But, while the concrete value of a struct type has multiple fields, a concrete value of an enum type is exactly one variant.

Enums enable you to build clearer types and decrease the number of illegal states that your data can take.

Let’s look at an example. We want to model a character in a game.

It can have three states:

  • Alive. In this case, it has life points.
  • Knocked out. In this case, it has life points and a counter of turns it needs to wait to regain consciousness.
  • Dead. In this case, it doesn’t need any additional fields.

Modeling it with only a struct is quite awkward. But we can try the following:

struct Player {
    state: String,
    life: i8,
    turns_to_wait: i8,
}

Here, we store the name of the state in a string. If the character is “dead”, their life should be 0. And if they are “knocked out”, they should have some turns to wait until they can act.

let dead_player = Player {
    state: "dead".to_string(),
    life: 0,
    turns_to_wait: 0,
};

let knocked_out_player = Player {
    state: "knocked out".to_string(),
    life: 50,
    turns_to_wait: 3,
};

This works, but it’s very wonky and obscures the states in type. You need to read the code or docs to know the available states, which diminishes the value of types as documentation. You can also use any kind of string in your code, such as “daed” or “knacked out”, without a compiler error.

Using an enum, we can list all the possible states in a readable manner.

enum Player {
    Alive{life: i8},
    KnockedOut{life: i8, turns_to_wait: i8},
    Dead,
}

let dead_player = Player::Dead;
let knocked_out_player = Player::KnockedOut { 
    life: 50, 
    turns_to_wait: 3, 
};

Pattern Matching

Enum types can have one of multiple variants. So if a function takes an enum, we need a way to adapt the function’s behavior depending on the variant of data it will encounter.

This is done with pattern matching. If variants construct values of a type, pattern matching deconstructs them.

Recall our Bool data type:

enum Bool {
    True,
    False,
}

Suppose we want to create a function called neg that returns the opposite of the boolean that we provide.

This is how we can do it in Rust using pattern matching.

fn neg(value: Bool) -> Bool {
//        [1]
    match value {
//      [2]           [3]
        Bool::True => Bool::False,
        Bool::False => Bool::True,
    }
}

// [1]: The value we’re pattern matching on.
// [2]: Pattern we want to match. 
// [3]: Result.

The result of a match statement can be either an expression or a statement.

fn print_value(value: Bool) {
    match value {
        Bool::True => println!("Clearly true!"),
        Bool::False => println!("Unfortunately false!"),
    }
}

If you want the result to be more than a single statement or expression, you can use curly braces.

fn print_and_return_value(value: Bool) -> Bool {
    match value {
        Bool::True => {
            println!("Clearly true!");
            Bool::True
        }
        Bool::False => {
            println!("Unfortunately false!");
            Bool::False
        }
    }
}

While this basic case highly resembles a case/switch statement, pattern matching can do much more. We can use pattern matching to assign values while choosing the code path to execute. This enables us to easily work with nested values.

fn take_five(player: Player) -> Player {
    match player {
        Player::Alive { life: life } => Player::Alive { life: life - 5 },
        Player::KnockedOut {
            life: life,
            turns_to_wait: turns_to_wait,
        } => Player::KnockedOut {
            life: life - 5,
            turns_to_wait: turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

To make the code example above even simpler, we can use the field init shorthand. It lets us substitute life:life with life in pattern matching and construction of structs.

fn take_five(player: Player) -> Player {
    match player {
        Player::Alive { life } => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

The code above has one problem: it doesn’t take into account the fact that a player will die if their health reduces to 0. We can fix that with the help of match guards. and wildcards.

  • Match guards enable you to add if conditions to the pattern. So we can make the pattern match if the life of Player::Alive is larger than 5, for example.

  • If you put a wildcard (marked by underscore) in a pattern, it will match anything. It can be used at the end of a match statement to handle all the remaining cases.

fn take_five_advanced(player: Player) -> Player {
    match player {
        Player::Alive { life } if life > 5 => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } if life > 5 => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        _ => Player::Dead,
    }
}

The match statement must be exhaustive – it needs to cover all the possible values. If you fail to cover some of the options, the program won’t compile.

For example, if we didn’t use the underscore at the end of the statement, we would have two states that are not covered: Alive and KnockedOut with less than 5 life points. The compiler can detect this and reject the code.

//error[E0004]: non-exhaustive patterns: `Alive { .. }` and `KnockedOut { .. }` not covered

fn take_five_advanced(player: Player) -> Player {
    match player {
        Player::Alive { life } if life > 5 => Player::Alive { life: (life - 5) },
        Player::KnockedOut {
            life,
            turns_to_wait,
        } if life > 5 => Player::KnockedOut {
            life: (life - 5),
            turns_to_wait,
        },
        Player::Dead => Player::Dead,
    }
}

Enum methods and traits

Like structs, enums allow for the creation of associated methods and the implementation of traits. They are a powerful tool for clearing up your code and reducing boilerplate.

Defining methods for enums

Take a look at the negation function that we implemented earlier.

fn neg(value: Bool) -> Bool {
  match value {
    Bool::True => Bool::False,
    Bool::False => Bool::True,
  }
}

It would be better to have it be a method so that we can access it via dot-notation.

let is_true = Bool::True;
let not_true = is_true.neg();

To do that, we need to create an implementation block for Bool. In it, we use the self keyword to refer to the Bool value at hand. Self stands for the type we’re writing the implementation for.

impl Bool {
  fn neg(self) -> Self {
    match self {
      Self::True => Self::False,
      Self::False => Self::True,
    }
  }
}

Defining traits for enums

Like structs, enums can also have traits – Rust’s version of interfaces that enable common functionality among types.

You can derive common traits such as Debug, Clone, and Eq using the derive attribute.

#[derive(Debug, Eq, PartialEq)]
enum Bool {
  True,
  False,
}

For example, after deriving Debug and PartialEq, we can now print and compare values of Bool.

let is_true = Bool::True;
let is_false = Bool::False;
println!("{:?}", is_true); // Prints “True”. 
let are_same = is_true == is_false; // false 

It’s also possible to create your own custom traits and implementations. For more info on this, you can read our article on traits.

Option and Result enums

Two enums you will frequently encounter in Rust are Option, Rust’s safe alternative to null, and Result, Rust’s safe alternative to exceptions.

Where can they be used? Well, sometimes a function is not able to return a result for a given input. As a simple example, you cannot divide a number by 0.

let impossible = 4 / 0;

In fact, Rust doesn’t let you compile a line of code that divides by zero. But you can still do it in multiple other ways.

fn main() {
  println!("Choose what to divide 4 with!");
  let mut input = String::new();
  std::io::stdin().read_line(&mut input).unwrap();
  let divisor: i32 = input.trim().parse().unwrap();

  let result = 4 / divisor;
}

But what if we don’t want the program to crash whenever we encounter a division by zero? We can instead make the function return the Option type and handle the problem further down the code.

Option has two variants: None or Some. The first represents a lack of output – something you would commonly use null or nil for. Some can wrap any type and signifies that the function has successfully returned a value.

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

For example, we can wrap division in Option so it doesn’t panic when we divide by 0.

fn safe_division(a: i32, b: i32) -> (Option<i32>) {
    match b {
        0 => None,
        _ => Some(a / b),
    }
}

let possible = safe_division(4, 0); // None

Result is similar, but it returns the error encountered instead of returning None.

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

Here’s how safe_division looks with Result:

#[derive (Debug, Clone)]
struct DivideByZero; // custom error type

fn safe_division_result(a: i32, b: i32) -> (Result<i32, DivideByZero>) {
    match b {
        0 => Err(DivideByZero),
        _ => Ok(a / b),
    }
}

let also_possible = safe_division_result(4, 0); // Err(DivideByZero)

As you can see, we created a custom error type for the error. It’s possible to use strings to denote errors, but it’s not recommended.

Option and Result methods

Both of these enums are useful, but they tend to complicate the code.

Here are some commonly used methods that will make working with them easier. They work on both Option and Result types.

unwrap

The straightforward way to get the value inside an Option or a Result is to call unwrap on it.

This method has a big downside, though. Calling it on a None or Error will panic the program, defeating the purpose of error handling.

let some = safe_division(6, 3); // Some(2)
let none = safe_division(4, 0); // None

let two = some.unwrap(); // 2 
let oops = none.unwrap(); // thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'

Therefore, its use is generally discouraged. It can come in handy when you’re writing prototypes, using Rust for simple scripts, or trying out libraries.

unwrap_or_else

A safe way to unwrap values is to use the unwrap_or_else method.

It takes a closure (anonymous function) as an argument. If it runs into None or Err, it uses this closure to compute a value to use instead of panicking.

For example:

let two = Some(2).unwrap_or_else(|| {0}); // 2
let safe = None.unwrap_or_else(|| {0}); // 0

There are two more unwrap variants that you can use:

  • unwrap_or, which lets you provide a value directly. The difference between it and unwrap_or_else is that it calculates the default value anytime you run the function, while unwrap_or_else calculates it only when necessary.
  • unwrap_or_default, which provides a default value instead of panicking if the type has a Default trait implemented.

?

The question mark (try) operator is a way to get values out of Option and Result without handling the None case.

You can write it at the end of any function call that returns Option or Result that is inside a function block that returns Option or Result.

If it encounters a success state, it will extract the value and proceed with the function. If it encounters a fail state (None or Err of some sort), it will short-circuit the function and return the result.

For Option, it is approximately equivalent to:

match expr {
    Some(x) => x,
    None => return None,
}

Let’s look at an example: a function that performs safe division twice.

fn safe_division_twice(a: i32, b: i32, c: i32) -> Option<i32> {
    let result = safe_division(a, b)?;
    safe_division(result, c)
}

let one = safe_division_twice(4, 2, 2); // Some(1)
let oops = safe_division_twice(4, 0, 2); // None

It can be very useful when chaining multiple functions that use one of these enums. The alternative of safe_division_twice with match is quite wordy.

fn safe_division_twice(a: i32, b: i32, c: i32) -> Option<i32> {
    match safe_division(a, b) {
        Some(x) => safe_division(x, c),
        None => None,
    }
}

But it would become even worse when we need to chain more than two of these functions: each operation requires its own nested match statement.

How a safe_division_thrice function looks with match vs. ?

With pattern matching

fn safe_division_thrice(a: i32, b: i32, c: i32, d: i32) -> Option<i32> {
    match safe_division(a, b) {
        Some(x) => match safe_division(x, c) {
            Some(y) => safe_division(y, d),
            None => None,
        },
        None => None,
    }
}

With the question mark operator

fn safe_division_thrice(a: i32, b: i32, c: i32, d: i32) -> Option<i32> {
    let first = safe_division(a, b)?;
    let second = safe_division(first, c)?;
    safe_division(second, d)
}

and_then

Another way to chain these enums without writing deep pattern matching statements is the and_then combinator.

let one = safe_division(4, 2)
    .and_then(|x| safe_division(x, 2));

let oops = safe_division(4, 0)
    .and_then(|x| safe_division(x, 2));

It extracts the wrapped value and then applies the function provided as its argument to it. In case the value is None or Error, it will short circuit the function and return that value.

In our case, it will extract the result of the first function. Then, it will take that result and pass it to the anonymous function we provided, which is the same as safe_division, but with the second argument already provided.

Conclusion

In this article, we covered the basics of enums in Rust. We looked at what they are and how they can be useful. We also looked at pattern matching, a powerful tool that can be used with enums. We also briefly covered two common enums – Option and Result – and the methods that you can use to handle them.

If you would like to read more beginner-friendly articles about Rust, be sure to follow us on Twitter or subscribe to our newsletter via the form below.

If you consider implementing Rust in your company’s IT solutions, check out our Rust app development and consulting services.

Banner that links to Serokell Shop. You can buy cool FP T-shirts there!
More from Serokell
Rust in Production: MeiliSearchRust in Production: MeiliSearch
hackage search picturehackage search picture
Rust lang: 9 companies that use RustRust lang: 9 companies that use Rust