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
andResult
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 ofPlayer::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
Option
and Result
enumsTwo 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
Option
and Result
methodsBoth 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
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
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 andunwrap_or_else
is that it calculates the default value anytime you run the function, whileunwrap_or_else
calculates it only when necessary.unwrap_or_default
, which provides a default value instead of panicking if the type has aDefault
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
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.