Rust Fundamentals

Structs, Enums, and Error Handling


Learning Objectives

  • You can define and use structs to create custom data types.
  • You understand how to implement methods and associated functions.
  • You can work with enums to represent different variants of data.
  • You understand the Option<T> enum and how to handle absent values.
  • You can use the Result<T, E> type for error handling.
  • You know when and how to use the ? operator for error propagation.
  • You know when and how to use Box<T> for recursive types.

Warming up

Here is a set of tiny programming tasks to get you familiar with syntax that relates to structs, enums, and error handling. You can solve these exercises directly in the embedded code editor.

Loading Exercise...

Defining Structs

A struct (short for structure) is a custom data type that lets you package together related values. If you’re familiar with objects in other languages, structs are similar - they group data that belongs together.

Let’s create a struct to represent a menu item in our coffee shop:

The struct definition declares the fields (name, price, calories) and their types. To create an instance of the struct, we provide values for each field.

We access fields using dot notation: espresso.name, espresso.price, etc.

Mutable Structs

Like all variables in Rust, struct instances are immutable by default. To modify fields, the entire instance must be mutable:

Note: Rust doesn’t allow you to mark individual fields as mutable - either the entire struct instance is mutable or none of it is. This keeps things simple and predictable.

Loading Exercise...

Field Init Shorthand

When the variable name matches the field name, you can use a shorthand:

Struct Update Syntax

You can create a new instance based on an existing one:

Note: Be careful with the struct update syntax and ownership. If the original struct contains non-Copy types (like String), those fields will be moved. In the example above, we specified name explicitly, so only calories is copied from espresso.

Methods and Associated Functions

Structs are useful, but we can make them even more powerful by adding methods - functions that operate on instances of the struct.

Implementing Methods

Methods are defined in an impl (implementation) block:

Let’s break down the three method signatures:

  1. &self - Borrows the instance immutably. The method can read but not modify.
  2. &mut self - Borrows the instance mutably. The method can modify the instance.
  3. self - Takes ownership of the instance. The instance is consumed and can’t be used after.

Most methods use &self or &mut self. Using self (taking ownership) is less common and typically used when transforming an instance into something else.

Associated Functions

Associated functions are like methods, but they don’t take self as a parameter. They’re called on the type itself, not on an instance:

Note: By convention, many structs have a new() associated function that creates instances. Unlike some languages, new isn’t a keyword in Rust - it’s just a naming convention.

Notice how we call associated functions: MenuItem::new() - using the struct name and ::. We call methods: latte.display() - using an instance and ..

Multiple impl Blocks

You can have multiple impl blocks for the same struct. This is usually not necessary, but it’s allowed:


Loading Exercise...

Enums

While structs let you group related data together, enums let you define a type that can be one of several variants. Enums are perfect for representing data that can be one thing or another.

Defining Enums

Let’s create an enum for drink sizes:

Each variant (Small, Medium, Large) is accessed through the enum name with ::.

Enums in 𝕊𝕋𝕃ℂ++

𝕊𝕋𝕃++ doesn’t directly support creating enums, but as we covered in 7 Objects and Compatibility one could emulate them using sum types.

As a reminder, to define an enum of “three variants”, we can write Unit + Unit + Unit.

Let’s recreate the above DrinkSize example in 𝕊𝕋𝕃++:

Enums with Data

Enums become really powerful when variants can hold data:

Notice how each variant can have different types and amounts of data:

  • Cash has a single f64 value
  • Card has named fields like a struct
  • MobileApp has a single String value
Loading Exercise...

Methods on Enums

Just like structs, enums can have methods:


Loading Exercise...

Box and Recursive Types

Now that we understand enums, let’s explore a powerful feature that’s essential for building complex data structures: recursive types.

The Problem: Infinite Size

Sometimes you want to create a type that contains itself. For example, imagine we want to create an order that is composed of a list of menu items. Each item in the order could point to the next item:

enum OrderList {
  Node(MenuItem, OrderList),
  Empty,
}

Note: You might notice that the above is a linked list. In reality, we’d build this with a vector or similar. We’ll learn about collections in the next chapter. The Node is a name we give to each element in the list, containing a MenuItem and a pointer to the next OrderList — it could, e.g., as well be called Element or Item.

When you try to compile this, Rust will complain. Why? Because it can’t determine the size of OrderList. Each Node contains another OrderList, which contains another OrderList, and so on - leading to infinite size! Rust gives us this error:

error[E0072]: recursive type `OrderList` has infinite size
Needing to Know Sizes at Compile Time

Rust needs to know how much memory to allocate for each type at compile time. But with our OrderList definition above, how big is a OrderList?

  • It contains a MenuItem and another OrderList
  • And another OrderList (which contains a MenuItem and another OrderList)
  • Which contains a MenuItem and another OrderList
  • Forever!

The size is infinite, so knowing the size at compile time is impossible.

Enter Box<T>

Box<T> is a smart pointer that allocates values on the heap instead of the stack. The Box itself has a known, fixed size (it’s just a pointer), even though it can point to any amount of data.

Fig 1. — Box stores a pointer on the stack that points to data on the heap.

Here’s how we fix our list:

enum OrderList {
  Node(MenuItem, Box<OrderList>),
  Empty,
}

As a whole, a program using Box<OrderList> looks like this:

Now the OrderList has a known size:

  • The MenuItem has a fixed size
  • The Box<OrderList> is 8 bytes (just a pointer on a 64-bit system)
  • Total: fixed size (plus some alignment)

The actual list data lives on the heap, and we just hold pointers to it.

Loading Exercise...

Smart Pointer and Polymorphism

A smart pointer is a data structure that behaves like a pointer but also has additional capabilities, such as automatic memory management. In Rust, Box<T> is a smart pointer that allocates data on the heap and automatically deallocates it when it goes out of scope.

Smart pointers enable polymorphism by allowing you to work with different types through a common interface. Polymorphism broadly means here that Box<OrderList> can point to any variant of the OrderList enum, allowing the recursive structure to handle lists of any length with a fixed-size pointer.

Working with Recursive Types

Let’s make our list more useful by adding methods:

Notice how we can pattern match on Box<OrderList> just like we would on OrderList - Rust automatically dereferences it for us!

Note: A cool thing in the above example is also how the sum and len methods are implemented recursively. Each method calls itself on the next item in the list until it reaches the Empty variant.

Example: Binary Tree

As another example, let’s look into a binary tree, which is typically discussed in introductory data structures and algorithms courses. The example uses Box<T> to handle recursion. The #[derive(Debug)] attribute automatically implements the Debug trait, which lets us print the enum with {:#?}.


When to Use Box

Use Box<T> when you (1) have a recursive type or need heap allocation, (2) need a type with unknown size at compile time, (3) want to transfer ownership of large data without copying, or (4) need trait object (we’ll look into traits in Chapter 7).


Loading Exercise...

The Option Enum

Rust doesn’t have null like many other languages. Instead, it has the Option<T> enum to represent a value that might be absent:

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

Note: Option is automatically imported in every Rust program. You don’t need to write Option::Some or Option::None - just Some and None work.

Using Option

Let’s use Option to represent items that might be in stock:

The Option<f64> return type tells us: “This function might return a price, or it might not.” The caller must handle both cases.

Unwrapping Options

There are several ways to get the value out of an Option:

Note: Avoid unwrap() in production code! It will crash your program if the value is None. Use it only when you’re absolutely certain the value exists, or in examples and quick prototypes.

Loading Exercise...

Option Methods

Option has many useful methods:


Loading Exercise...

The Result Type

While Option represents a value that might be absent, Result<T, E> represents an operation that might fail:

enum Result<T, E> {
  Ok(T),
  Err(E),
}
  • Ok(T) contains the success value
  • Err(E) contains the error information

Using Result

Let’s create a function that might fail:

The Result<i32, String> tells us:

  • Success: returns an i32
  • Error: returns a String describing what went wrong
Loading Exercise...

Handling Results

Like Option, there are several ways to handle Result:


Loading Exercise...

The ? Operator

The ? operator is a shorthand for error propagation. If the Result is Ok, it unwraps the value. If it’s Err, it returns the error from the current function:

Note: The ? operator can only be used in functions that return Result or Option. You can’t use it in main() unless you change main’s signature to return a Result.

Here’s an example of using ? multiple times:

Each ? checks if the operation succeeded. If any operation fails, the function immediately returns with that error.

Loading Exercise...

Creating Custom Error Types

Using String for errors works but isn’t ideal. It’s better to create custom error types that clearly describe what can go wrong:

Custom error types make your code more self-documenting and allow callers to handle different errors differently:


Loading Exercise...

Example: Coffee Shop System

Let’s bring everything together in a coffee shop order management system using recursive types:

This example demonstrates:

1. Using Option<OrderList>

struct Order {
    items: Option<OrderList>,
}

Wrapping the list in Option allows us to temporarily take ownership — this allows us to modify the list without cloning it.

2. Option’s take() Method

if let Some(old_items) = self.items.take() {
    self.items = Some(old_items.add(order_item));
}

take() removes the value from the Option, leaving None behind. This gives us ownership of old_items so we can pass it to add().

3. Working with Option<T>

fn total(&self) -> f64 {
    self.items.as_ref()
        .map(|list| list.total())
        .unwrap_or(0.0)
}

We use as_ref() to borrow the value inside the Option, then map() to transform it, and unwrap_or() to provide a default.

4. Pattern Matching on Option

match &self.items {
    Some(list) if !list.is_empty() => { /* ... */ }
    _ => Err(OrderError::EmptyOrder),
}

We can match on the Option and add guard clauses for additional checks.

Loading Exercise...

Summary

In this chapter, we covered essential Rust types and patterns:

  • Structs group related data together into custom types
  • Methods are defined in impl blocks with &self, &mut self, or self
  • Associated functions don’t take self and are called with ::
  • Enums represent data that can be one of several variants
  • Enums can hold data making them very flexible
  • Box<T> enables recursive types by storing data on the heap
  • Recursive types like trees and lists require Box because they contain themselves
  • Option<T> replaces null and forces you to handle absent values
  • Result<T, E> represents operations that can fail
  • The ? operator simplifies error propagation in Result-returning functions
  • Custom error types make code more maintainable and self-documenting