Structs, Enums and Traits

Structs, enums, pattern matching, Option and Result error handling with ?, and traits.

Structs

A struct groups related data together under one named type, similar in spirit to a class's fields but without inheritance:

Rust
struct User {
    username: String,
    email: String,
    active: bool,
}

fn main() {
    let user1 = User {
        username: String::from("ali"),
        email: String::from("ali@example.com"),
        active: true,
    };

    println!("{} <{}>", user1.username, user1.email);
}

Methods are defined separately, in an impl (implementation) block:

Rust
impl User {
    // an associated function (no `self`) — called like User::new(...), a common constructor pattern
    fn new(username: &str, email: &str) -> User {
        User {
            username: username.to_string(),
            email: email.to_string(),
            active: true,
        }
    }

    // a method — takes &self, borrows the instance
    fn describe(&self) -> String {
        format!("{} <{}>", self.username, self.email)
    }

    // a method that mutates the instance needs &mut self
    fn deactivate(&mut self) {
        self.active = false;
    }
}

fn main() {
    let mut user1 = User::new("ali", "ali@example.com");
    println!("{}", user1.describe()); // ali <ali@example.com>

    user1.deactivate();
    println!("{}", user1.active); // false
}

Rust also has tuple structs (fields identified by position, useful for lightweight wrapper types) and unit structs (no fields at all, useful for marker types):

Rust
struct Point(f64, f64);

let origin = Point(0.0, 0.0);
println!("{} {}", origin.0, origin.1);

Enums

A Rust enum defines a type by listing all the values it could possibly be — and unlike enums in many other languages, each variant can carry its own data:

Rust
enum WebEvent {
    PageLoad,                       // a variant with no data
    Click { x: i64, y: i64 },       // a variant with named fields
    KeyPress(char),                 // a variant with one unnamed field
}

Pattern matching with match

match compares a value against a set of patterns and runs the code for the first match — and critically, it's exhaustive: the compiler refuses to build your program if you forget a case.

Rust
fn describe(event: WebEvent) -> String {
    match event {
        WebEvent::PageLoad => String::from("page loaded"),
        WebEvent::Click { x, y } => format!("clicked at ({x}, {y})"),
        WebEvent::KeyPress(c) => format!("pressed '{c}'"),
    }
}

fn main() {
    println!("{}", describe(WebEvent::Click { x: 10, y: 20 })); // clicked at (10, 20)
}

match also works well with ranges and guards, and _ acts as a catch-all:

Rust
fn categorize(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1..=9 => "single digit",
        n if n < 0 => "negative",
        _ => "large",
    }
}

Option<T> — no null pointers

Rust has no null. Instead, any value that might be absent is represented with the standard library's Option<T> enum:

Rust
enum Option<T> {
    Some(T),
    None,
}
Rust
fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Ali"))
    } else {
        None
    }
}

fn main() {
    match find_user(1) {
        Some(name) => println!("Found: {name}"),
        None => println!("No user found"),
    }

    // common shortcuts for the "give me a default if it's None" case
    let name = find_user(2).unwrap_or(String::from("Guest"));
    println!("{name}"); // Guest
}

Because Option<T> and T are different types, the compiler forces you to explicitly handle the "might be absent" case before you can get at the value inside — there's no way to accidentally dereference a null pointer, because there are no null pointers.

Result<T, E> — no exceptions

Recoverable errors use Result<T, E>, another standard library enum:

Rust
enum Result<T, E> {
    Ok(T),
    Err(E),
}
Rust
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("division by zero"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10.0, 2.0) {
        Ok(result) => println!("Result: {result}"),
        Err(e) => println!("Error: {e}"),
    }
}

The ? operator

Manually matching on every Result gets verbose fast, especially when chaining several fallible operations. The ? operator propagates an Err immediately, returning it from the enclosing function — and unwraps the Ok value otherwise:

Rust
use std::num::ParseIntError;

fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
    let n: i32 = input.parse()?; // returns early with the Err if parsing fails
    Ok(n * 2)
}

fn main() {
    match parse_and_double("21") {
        Ok(n) => println!("Doubled: {n}"),   // Doubled: 42
        Err(e) => println!("Parse error: {e}"),
    }

    match parse_and_double("not a number") {
        Ok(n) => println!("Doubled: {n}"),
        Err(e) => println!("Parse error: {e}"), // Parse error: invalid digit found in string
    }
}

? can only be used in a function whose own return type is compatible (Result or Option), which keeps error propagation explicit and visible in every function signature — you can always tell which functions can fail just by reading their signature.

Traits — Rust's interfaces

A trait defines shared behavior a type can implement, similar to an interface in Java or Go:

Rust
trait Summary {
    fn summarize(&self) -> String;

    // traits can also provide a default implementation
    fn preview(&self) -> String {
        format!("Preview: {}", self.summarize())
    }
}

struct Article {
    title: String,
    body: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{}: {}...", self.title, &self.body[..20.min(self.body.len())])
    }
}

fn main() {
    let article = Article {
        title: String::from("Rust 101"),
        body: String::from("Rust is a systems programming language."),
    };

    println!("{}", article.summarize()); // uses the trait method
    println!("{}", article.preview());   // uses the default method
}

Traits are also how Rust achieves generic, polymorphic code — a function can accept "anything that implements Summary" either via impl Trait (static dispatch, resolved and specialized at compile time) or dyn Trait (dynamic dispatch through a vtable, resolved at runtime, useful when the concrete type isn't known until runtime, e.g. a mixed collection):

Rust
fn print_summary(item: &impl Summary) { // static dispatch — monomorphized per concrete type
    println!("{}", item.summarize());
}

fn print_summary_dyn(item: &dyn Summary) { // dynamic dispatch — one shared function, vtable lookup
    println!("{}", item.summarize());
}

Common mistakes

  • Forgetting match must be exhaustive and being surprised the compiler rejects a missing case — this is intentional; add a _ => ... arm if you genuinely want to ignore the rest.
  • Calling .unwrap() on an Option/Result in production code paths — it panics on None/Err, which is fine for prototypes and tests but rarely what you want in real request-handling code. Prefer match, ?, or combinators like .unwrap_or().
  • Reaching for dyn Trait everywhere out of habit — impl Trait / generics give you static dispatch (usually faster, since the compiler can inline and specialize) and should be the default unless you specifically need a heterogeneous collection or runtime polymorphism.

Interview questions

Q: Why does Rust use Option<T> instead of allowing null? Because Option<T> and T are distinct types, the compiler forces every call site that might receive an absent value to explicitly handle both the Some and None cases before accessing the inner value — eliminating null-pointer/reference errors as a runtime failure mode entirely.

Q: How does Result<T, E>-based error handling compare to exceptions? Errors are ordinary return values, visible directly in a function's signature (-> Result<T, E>), and the compiler forces the caller to acknowledge them (or explicitly propagate with ?). Exceptions can silently unwind through many stack frames with no signature-level indication that a function can fail — Result makes the failure path part of the type system instead.

Q: What's the difference between impl Trait and dyn Trait as a function parameter? impl Trait is resolved at compile time — the compiler generates a specialized version of the function per concrete type (static dispatch/monomorphization), which is typically faster but increases binary size. dyn Trait is resolved at runtime via a vtable (dynamic dispatch) — one shared function body, slightly slower per call, but usable when you need a single collection holding several different concrete types behind the same trait.