Skip to content
rusthacks.dev
Rust

Rust Enums and Why They Replace Class Hierarchies Explained

Rust enums carry data in each variant, which replaces the class hierarchy and the nullable field. See the syntax, match, methods, and where it beats inheritance.

9 min read
Summarize in AI
Flat illustration for the post “Rust Enums and Why They Replace Class Hierarchies Explained”, composed around enums.

If you come from an object-oriented language, your first instinct for "a value that could be one of several kinds" is a base class with subclasses. Rust's answer is the enum, and it is closer to a sum type in a functional language than to the enums of C or Java. Each variant is a different shape of data, and match is how you take it apart. This post builds that up from a small example, compares it with the trait-object version of the same problem, and measures what an enum costs in memory. Everything ran on Rust 1.99, edition 2024.

A type with several shapes

src/main.rsrust
#[derive(Debug)]
enum Message {
    Quit,                              // no data
    Move { x: i32, y: i32 },           // named fields
    Write(String),                     // one value
    Color(u8, u8, u8),                 // a tuple
}

impl Message {
    fn describe(&self) -> String {
        match self {
            Message::Quit => "quit".to_string(),
            Message::Move { x, y } => format!("move to ({x}, {y})"),
            Message::Write(text) => format!("write {text:?}"),
            Message::Color(r, g, b) => format!("color #{r:02x}{g:02x}{b:02x}"),
        }
    }
}

fn main() {
    let queue = vec![
        Message::Move { x: 3, y: -1 },
        Message::Write("hi".to_string()),
        Message::Color(255, 128, 0),
        Message::Quit,
    ];
    for m in &queue {
        println!("{}", m.describe());
    }
    println!("{:?}", queue[0]);
}
Text
move to (3, -1)
write "hi"
color #ff8000
quit
Move { x: 3, y: -1 }

Message has four variants and no two have the same shape. Quit carries nothing. Move { x, y } carries two named integers, like a struct. Write(String) carries one string, and Color(u8, u8, u8) carries three bytes as a tuple. A value of type Message is exactly one of these at a time. The method describe uses match self to take each one apart, and because self is a reference the patterns bind references into the data and nothing is moved.

The #[derive(Debug)] line is what lets {:?} print the last line, Move { x: 3, y: -1 }. Without a derive, the compiler tells you that the type does not implement Debug.

In a language without sum types you would build this with a class per message, a type tag field and a cast, or with a struct holding every possible field and a flag saying which ones are valid. Both let you read a field that does not belong to the current kind. In Rust you cannot read x from a Write, because the only way to reach the fields is to match, and the match only gives you the fields of the variant you matched.

Forgetting a variant is a compile error

The strongest property of enums is that match is exhaustive. Add a third shape and forget to handle it:

src/main.rsrust
enum Shape {
    Circle(f64),
    Square(f64),
    Triangle(f64, f64),
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Square(side) => side * side,
    }
}

fn main() {
    println!("{}", area(&Shape::Triangle(3.0, 4.0)));
}
Text
error[E0004]: non-exhaustive patterns: `&Shape::Triangle(_, _)` not covered
  --> src/main.rs:8:11
   |
 8 |     match s {
   |           ^ pattern `&Shape::Triangle(_, _)` not covered
   |
note: `Shape` defined here
  --> src/main.rs:1:6
   |

The error names the variant that is not covered, Triangle, and points at its definition. Here is the same code with the case handled:

src/main.rsrust
enum Shape {
    Circle(f64),
    Square(f64),
    Triangle(f64, f64),
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Square(side) => side * side,
        Shape::Triangle(base, height) => 0.5 * base * height,
    }
}

fn main() {
    let shapes = [Shape::Circle(1.0), Shape::Square(2.0), Shape::Triangle(3.0, 4.0)];
    for s in &shapes {
        println!("{:.3}", area(s));
    }
}
Text
3.142
4.000
6.000

The practical consequence for a codebase is larger than it looks. When someone adds a variant to a widely used enum, every match that lacks a wildcard stops compiling, and the compiler lists each one. The change cannot ship with a forgotten case. That is why Rust style avoids _ => catch-alls on enums you own: a wildcard is a promise that you never want to be told about new variants. The same mechanism powers Option and Result, which are enums in the standard library, not language magic.

Enum or trait object

The object-oriented version of the same shapes is a trait with an implementation per type:

src/main.rsrust
trait Shape {
    fn area(&self) -> f64;
}

struct Circle(f64);
struct Square(f64);

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
}
impl Shape for Square {
    fn area(&self) -> f64 { self.0 * self.0 }
}

fn main() {
    // Open set: anyone can add a new type that implements Shape.
    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(1.0)), Box::new(Square(2.0))];
    for s in &shapes {
        println!("{:.3}", s.area());
    }
}
Text
3.142
4.000

Both work. The difference is in who can extend them. With the trait, any crate can add a new type that implements Shape and put it into a Vec<Box<dyn Shape>>, and the code that calls area need not change. With the enum, the set of shapes is closed: only the crate that defines Shape can add one, and every match must be updated. Neither is better in general. They answer the question "who will add cases?" differently.

  • The enum fits when you control every case and operations are added often: parsers, protocol messages, state machines, command sets. A new operation is a new function with a match; a new case is one edit plus whatever the compiler lists.
  • The trait object fits when other people add the cases: plugins, drivers, anything behind an interface boundary. A new case is a new type; a new operation means changing the trait and every implementation.

Enums are also cheaper. A Vec<Shape> stores the values inline and a call is a branch on a tag, while Vec<Box<dyn Shape>> allocates each element and calls through a pointer. The full comparison with measurements is in static versus dynamic dispatch.

State machines

An enum is the natural way to write something that moves through states, because each state can carry exactly the data that is valid in it:

src/main.rsrust
#[derive(Debug)]
enum Connection {
    Idle,
    Connecting { attempt: u32 },
    Connected { session: String },
    Failed(String),
}

impl Connection {
    fn next(self) -> Connection {
        match self {
            Connection::Idle => Connection::Connecting { attempt: 1 },
            Connection::Connecting { attempt } if attempt < 3 => Connection::Connecting { attempt: attempt + 1 },
            Connection::Connecting { .. } => Connection::Failed("gave up after 3 attempts".to_string()),
            Connection::Connected { session } => Connection::Connected { session },
            Connection::Failed(why) => Connection::Failed(why),
        }
    }
}

fn main() {
    let mut c = Connection::Idle;
    for _ in 0..5 {
        println!("{c:?}");
        c = c.next();
    }
    println!("{c:?}");
    let ok = Connection::Connected { session: "abc".into() };
    println!("{:?}", ok.next());
}
Text
Idle
Connecting { attempt: 1 }
Connecting { attempt: 2 }
Connecting { attempt: 3 }
Failed("gave up after 3 attempts")
Failed("gave up after 3 attempts")
Connected { session: "abc" }

next takes self by value, so the old state is consumed and a new one returned; the compiler will not let you keep using the previous state by mistake. Connecting holds an attempt counter, Connected holds a session, and Failed holds a reason. There is no way to have a session on a failed connection, and no Option<String> field that is secretly only valid in one state. The guard if attempt < 3 retries twice and then moves to Failed; the last two lines show that a failed machine stays failed and that a connected one keeps its session.

What it costs in memory

An enum value holds the largest variant plus enough information to say which variant it is. Ask the compiler:

src/main.rsrust
use std::mem::size_of;

#[allow(dead_code)]
enum Small { A, B, C }

#[allow(dead_code)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    Color(u8, u8, u8),
}

fn main() {
    println!("Small:               {}", size_of::<Small>());
    println!("Message:             {}", size_of::<Message>());
    println!("String:              {}", size_of::<String>());
    println!("Option<u32>:         {}", size_of::<Option<u32>>());
    println!("Option<Box<u32>>:    {}", size_of::<Option<Box<u32>>>());
    println!("Option<&u32>:        {}", size_of::<Option<&u32>>());
    println!("Option<String>:      {}", size_of::<Option<String>>());
}
Text
Small:               1
Message:             24
String:              24
Option<u32>:         8
Option<Box<u32>>:    8
Option<&u32>:        8
Option<String>:      24

Small, three variants with no data, is a single byte: the tag is all there is. Message, with a String in one variant and an i32 pair in another, is 24 bytes, exactly the size of a String. The tag does not add 8 more because the compiler stores it in an unused bit pattern of the String's capacity field. Rust calls this niche optimisation. It is also why Option<Box<u32>>, Option<&u32> and Option<String> are the same size as the thing they wrap (8, 8 and 24 bytes): a pointer can never be null, so the null value is free to mean None. Option<u32> has no such spare value, so it is 8 bytes, twice a u32. For the layout rules behind these numbers see alignment and padding.

One lesson for design: an enum with one huge variant and many small ones makes every value as big as the huge one. If the shapes differ wildly, box the large variant's payload (Big(Box<BigThing>)) to keep the common case small.

Numbers behind the names

Sometimes the enum has to match an external numbering: a log level in a binary protocol, an opcode, a database column. Give the variants explicit values and convert back with a fallible function:

src/main.rsrust
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
enum Level {
    Debug = 10,
    Info = 20,
    Error = 40,
}

impl TryFrom<u8> for Level {
    type Error = String;
    fn try_from(n: u8) -> Result<Level, String> {
        match n {
            10 => Ok(Level::Debug),
            20 => Ok(Level::Info),
            40 => Ok(Level::Error),
            other => Err(format!("{other} is not a level")),
        }
    }
}

fn main() {
    println!("{}", Level::Info as u8);
    println!("{:?}", Level::try_from(40));
    println!("{:?}", Level::try_from(30));
    println!("{}", (Level::Error as u8) - (Level::Info as u8));
}
Text
20
Ok(Error)
Err("30 is not a level")
20

#[repr(u8)] fixes the representation at one byte and Level::Info as u8 reads the value, 20. The reverse direction needs TryFrom<u8>, because most u8 values are not a level; the example returns an error for 30 instead of inventing a variant. Never cast an arbitrary integer into an enum with unsafe or transmute: a value that matches no variant is undefined behaviour. The TryFrom match is the safe, boring alternative, and its final arm is where the unknown values become an error.

Habits

  • Name variants for the situation, not the type of data (Failed, not StringVariant).
  • Put data in the variant that needs it, instead of optional fields on one struct.
  • Avoid wildcard arms on your own enums; let the compiler list the places a new variant touches.
  • Derive Debug always, and Clone, PartialEq when you need them.
  • Keep the discriminant numbers out of your logic; convert at the edges.

Where an enum is the wrong tool

An enum that keeps growing to cover every case in a plugin system is a sign that the set is not closed, and a trait is the better boundary. An enum with dozens of variants that each need nearly identical handling suggests a struct with a kind field and shared data. And if you only need a flag with two values, a bool is clearer than enum Enabled { Yes, No } unless the call sites would otherwise be a wall of unlabeled true and false, in which case the named variants are worth it.

Also inGenerics
Taggedrustenums
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.