Structs and Enums

Structs with methods, Option and custom enums, match, method receivers, and newtypes that make bad states unrepresentable.

Structs and enums

Ownership tells you who holds a String. This lesson names groups of data, then the one-of-many values that replace boolean flags. Patterns and method receivers come after those two shapes, because both of them use the shapes. Typestate is last: it needs a consuming method.

Structs

A struct groups related data under a name. That is the domain model.

FormShapeUse it for
Named fieldsstruct User { id: u64, email: String }The usual domain model
Tuple structstruct Color(u8, u8, u8)A small ordered group, or a newtype with one field
Unit structstruct LoggedInA marker with no data
Userid and email ptra@b.comheap
StatusThe struct is the owner

id sits in the stack frame beside the String’s pointer, length, and capacity. You already know that String layout.

impl User attaches functions. new is an associated function: no self, and it returns Self. email(&self) borrows to read. set_email(&mut self, e: String) borrows to change. into_email(self) takes ownership. &self reads, &mut self changes, self consumes. The section on receivers is the full decision, after enums, because you need both shapes first.

let u2 = User { email: new_email, ..u1 } copies or moves the fields you did not name. Fields are private outside their module. Keep them private and offer a constructor that protects the invariant, such as an email that must contain @. A newtype, struct Metres(f64) beside struct Seconds(f64), stops you passing a duration where a length belongs. It costs nothing at runtime. The last section of this lesson uses that idea for identities and for stages of a document.

01_structs_and_impl.rsRust
struct Metres(f64);

struct User {
    id: u64,
    email: String,
}

impl User {
    fn new(email: String) -> Result<Self, &'static str> {
        if !email.contains('@') {
            return Err("email must contain @");
        }
        Ok(Self { id: 0, email })
    }

    fn email(&self) -> &str {
        &self.email
    }
}

fn main() {
    let user = User::new(String::from("a@b.com")).expect("email");
    let metres = Metres(3.0);
    println!("{} {}", user.email(), metres.0);
}

Enums and Option

An enum is one of several variants. It replaces null, a pile of booleans, and states that cannot happen together. enum IpAddr { V4(u8, u8, u8, u8), V6(String) } holds either four bytes or a string, never both.

V4tag and four bytesV6tag and a string
StatusOnly one variant is live

The value carries a tag. V4 stores 127, 0, 0, 1. The V6 string is not there.

Option is the enum that removes most null crashes: None or Some(T). Absence is in the type. Reach for these in order. unwrap and expect panic on None. Libraries avoid them.

FormWhat it does
matchNames every case, including None
if let Some(v) = optHandles the one variant you care about
unwrap_orSupplies a default value
unwrap_or_elseComputes the default only when the value is None
mapTransforms the inner value and leaves None alone
and_thenChains a step that itself returns Option
ok_orTurns an Option into a Result. That conversion is the next lesson.

struct Order { paid: bool, shipped: bool } allows shipped without paid. enum Order { Draft, Paid { at: Timestamp }, Shipped { tracking: String } } does not. A match on Order forces every business state. Methods on an enum are an impl that matches self.

02_enums_and_option.rsRust
enum Order {
    Draft,
    Paid { at_unix: u64 },
    Shipped { tracking: String },
}

fn label(order: &Order) -> &str {
    match order {
        Order::Draft => "draft",
        Order::Paid { .. } => "paid",
        Order::Shipped { tracking } => tracking.as_str(),
    }
}

fn main() {
    let order = Order::Shipped { tracking: String::from("1Z") };
    let maybe = Some(7);
    println!("{} {}", label(&order), maybe.unwrap_or(0));
}

Patterns

A match arm is a pattern, an optional guard, and an expression. Learn the toolkit in this order, because each one nests inside the previous.

PatternExample
Literal1 | 2 | 3
Range1..=9
Bindingx names the value
StructPoint { x, y }
EnumSome(n), Err(e)
Ignore_ throws the rest away. rest @ ... names it.
NestedSome(User { id, .. })
Guardn if n % 2 == 0
At-bindingn @ 1..=5 keeps the name and the range
valueinarm 1patternarm 2pattern_rest
StatusArms are tried in order

The first pattern that fits runs its expression. A guard can still reject that arm.

if let Some(x) = opt handles the one variant you care about. while let Some(n) = stack.pop() loops while that variant keeps appearing. let Some(user) = find(id) else { return Err(...) } is the guard-clause form: either user is bound, or the function returns. Matching a reference usually lets you write the pattern as if you had the value. Prefer match on enums over a soup of booleans. While you are still designing, name an ignored piece _reason instead of throwing it away.

03_pattern_matching.rsRust
struct Point {
    x: i32,
    y: i32,
}

fn classify(n: i32) -> &'static str {
    match n {
        n @ 1..=5 if n % 2 == 0 => "small even",
        1..=9 => "small",
        _ => "other",
    }
}

fn main() {
    let p = Point { x: 1, y: 2 };
    let Point { x, y } = p;
    println!("{x},{y} {}", classify(4));
    let mut next = Some(2);
    while let Some(n) = next {
        println!("{n}");
        next = if n == 0 { None } else { Some(n - 1) };
    }
}

Method receivers

The first parameter decides what the call does to the caller’s value.

ReceiverCaller still owns itUse it for
&selfYesThe default. Queries and formatting.
&mut selfYes. The binding must be mut.An update in place
selfNoA transform that invalidates the old value: into_string, into_iter
mut selfNo. You return the changed value.A builder chain. Rarer than the other three.
vcaller ownsv.read()&selfv.bump()&mut selfv.into_parts()self
StatusRead and update keep the value

&self is the default: queries and formatting. &mut self updates in place, and the binding must be mut. The caller still owns v.

A builder often looks like Client::builder().timeout(...).build(), chaining &mut self or self. A getter should return &str or a slice, not a clone. From and Into are the standard library’s consuming conversions. You will see the trait names when traits are introduced. The method shape is the part that matters now: into_* takes self.

04_method_receivers.rsRust
struct Buffer {
    data: String,
}

impl Buffer {
    fn as_str(&self) -> &str {
        &self.data
    }

    fn push(&mut self, extra: &str) {
        self.data.push_str(extra);
    }

    fn with_suffix(mut self, suffix: &str) -> Self {
        self.data.push_str(suffix);
        self
    }

    fn into_string(self) -> String {
        self.data
    }
}

fn main() {
    let mut buf = Buffer { data: String::from("hi") };
    buf.push("!");
    let owned = buf.with_suffix(".").into_string();
    println!("{owned}");
}

Newtypes and typestate

struct UserId(u64) and struct Email(String) stop the wrong plain value reaching an API. There is no extra runtime cost. You saw Metres and Seconds for the same reason.

Typestate puts the lifecycle in the type, so an illegal step does not compile. Post and Post are different types. publish exists only on a draft, takes self, and returns a published post. A published post has no publish method, so it cannot be published twice. The word in angle brackets is only a label here. The generics lesson explains type parameters in general. PhantomData tells the compiler the label is part of the type even though no field stores a Draft value.

Post<Draft>edit, publishPost<Published>no second publishpublish(self)
StatusThe draft is its own type

edit can exist on a draft. The published methods are not on this type, so you cannot call them by mistake.

The same split shows up as a connection that is closed or open, and as a request builder that is not sealed yet. The illegal state is not a boolean you have to remember to check.

05_newtype_typestate.rsRust
struct Draft;
struct Published;

struct Post<State> {
    body: String,
    state: std::marker::PhantomData<State>,
}

impl Post<Draft> {
    fn new(body: String) -> Self {
        Self { body, state: std::marker::PhantomData }
    }

    fn publish(self) -> Post<Published> {
        Post { body: self.body, state: std::marker::PhantomData }
    }
}

fn main() {
    let draft = Post::<Draft>::new(String::from("hello"));
    let _published = draft.publish();
}