If you aren't amazed by Rust enum, you don't know its power
Every time I explain Rust's enums to someone new, I get the same reaction: "Wait… enums can do that?"
I've used enums in C, Java, Go, and plenty of other languages, and they were fine. Just named constants. Useful, sure, but honestly a little boring.
Then I saw what Rust did with enums: each option can hold real data inside it, the compiler makes sure you don't forget to handle any case, and the type system blocks you from creating combinations that don't make sense. And the kicker? It all compiles down to something efficient, with zero extra indirection and often memory-optimal layouts.
That's when it clicked: this isn't just an enum. This is one of Rust's secret weapons.
In this post, I'll show you why Rust's enums deserve your respect, how you've been relying on them without even noticing, and what makes them so efficient once you see how they're laid out in memory. By the end, I hope you'll never look at Rust enums the same way again.
What's different about enums in Rust?
In most languages, enums are just names slapped on top of integers. They don't hold data, and the compiler won't stop you from forgetting to handle a case.
Rust takes a very different approach.
The first big difference: Rust enums can carry data. A variant isn't just a label. It can bundle real, typed information right inside.
enum Shape {
Circle(f64),
Square(f64),
Rectangle(f64, f64),
}
Here, Shape isn't just "Circle = 0, Square = 1." Each variant carries the exact data it needs: a radius for Circle, a side length for Square, and width + height for Rectangle. This way, you can't create an impossible state. Every variant has exactly the fields it should, nothing extra, nothing missing.
You can even give the data names if it makes your code clearer:
enum Shape {
Circle { radius: f64 },
Square { side: f64 },
Rectangle { width: f64, height: f64 },
}
Now when you create a shape, it's obvious what each number represents: Shape::Rectangle { width: 10.0, height: 5.0 }
instead of trying to remember whether Rectangle(10.0, 5.0)
means width-then-height or height-then-width.
The second big difference: Rust forces you to handle every case. With pattern matching, you don't just check which variant you got. The compiler makes sure you didn't forget any.
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Square(s) => s * s,
Shape::Rectangle(w, h) => w * h,
}
}
If you leave out Rectangle, this won't even compile. That's a huge step up from enums in other languages, where forgetting a case often triggers runtime bugs you might never notice.
Historical note: Rust didn't invent this magic. These ideas (variants that carry data and pattern matching) originated in the ML family (think Standard ML and OCaml) back in the 1970s, which pioneered what we now call tagged unions. Rust borrowed that power, but tailored it for systems programming.
These two differences (data inside variants and compiler-enforced case coverage) already set Rust's enums apart. But the real magic isn't just in the syntax. It's in the kinds of problems enums can solve for you. That's where their true power shows up.
Why enum
in Rust is powerful
Imagine you're designing a configuration for a file uploader. You want to support two modes: anonymous upload (no username needed) or authenticated upload (which requires a username and password). You might try this with a struct:
pub struct UploadConfig {
pub destination: String,
pub anonymous: bool,
// username and password should only be set if anonymous == false
pub username: Option<String>,
pub password: Option<String>,
}
This compiles, but it's awkward. Nothing prevents someone from setting anonymous = true and also filling in a username and password. Now you've got nonsense states your code has to tiptoe around. You're left relying on comments or runtime checks.
Enums fix this cleanly:
pub enum Auth {
Anonymous,
Credentials { username: String, password: String },
}
pub struct UploadConfig {
pub destination: String,
pub auth: Auth,
}
Now the invalid combination simply can't exist. If you have Anonymous, there's no username or password field lurking around. If you need credentials, you're forced to provide both. The compiler ensures every UploadConfig is valid by construction.
And that's the deeper point: Rust enums let you make invalid states impossible to represent. Entire categories of bugs disappear before your code even compiles. You're not just writing programs — you're encoding correctness into the design itself.
Two most common enums you use every day: Option
and Result
Even if you've never written your own enum, you've been using them constantly. Option<T>
is Rust's way of saying a value may or may not exist, without resorting to null. Its variants are simple:
enum Option<T> {
Some(T),
None,
}
Because it's an enum, you can't ignore the None
case. The compiler forces you to deal with it. That's why null pointer exceptions (the nightmare bug that haunts every other language) simply don't exist in Rust.
Similarly, Result<T, E>
encodes success or failure as an enum:
enum Result<T, E> {
Ok(T),
Err(E),
}
This design turns error handling into an explicit part of your program's flow. You can't accidentally forget that something might fail. The type system reminds you at every call site.
These two enums alone account for a huge amount of Rust's safety and reliability, and they're built on the same principles you can use in your own code.
If you're building systems software, you probably care about memory usage. So let's ask the practical question: how much space do these powerful enums actually take?
Dive deeper into Rust enum
memory layout
The answer might surprise you. Despite all their flexibility, Rust enums are remarkably compact. The compiler uses a clever layout strategy that minimizes waste while keeping access fast.
Here's how it works. For any enum, Rust needs to track two pieces of information in memory:
Which variant is currently active (the tag)
The data for that variant (the payload)
Rather than storing these separately, Rust packs them together as efficiently as possible. Let me show you exactly what this looks like.
Important Note: These memory layouts show how the current Rust compiler typically arranges enums, but this isn't guaranteed to stay the same. With the default repr(Rust)
representation, the compiler is free to change layout across different versions to optimize performance. You can rely on size_of::<T>()
and align_of::<T>()
for your compiled program, but the specific field arrangement and internal layout may change. Use these examples to understand concepts, not for low-level memory manipulation.
Example 1: Simple enum with no data
Start with the simplest case, an enum where variants carry no data:
enum Simple {
A,
B,
}
The memory layout is straightforward:
Since there's no payload data to store, Rust only needs a single byte to remember which variant you chose. No wasted space at all.
Example 2: Enum with data of different sizes
Now let's see what happens when variants carry data:
enum SingleOrPair {
One(u8), // needs 1 byte for the u8
Pair(u8, u8), // needs 2 bytes for two u8s
}
Here, Rust has to accommodate the largest possible variant (Pair
), plus space for the tag:
Since all components (u8
tag and u8
data fields) have 1-byte alignment, no padding is needed. The enum takes exactly 3 bytes: 1 for the tag plus 2 for the largest variant (Pair
).
The key insight: Rust allocates enough space for the largest variant, then reuses that same space for smaller variants. When you store One(42)
, it uses just 1 byte of the payload space. When you store Pair(10, 20)
, it uses both payload bytes. The enum always takes exactly 3 bytes regardless of which variant is active.
Example 3: Advanced optimization with niche-filling
Now for the really clever part. Sometimes Rust can eliminate the tag entirely through a technique called niche-filling. The compiler frequently applies this optimization when it detects unused bit patterns (though this behavior isn't guaranteed and may vary between compiler versions).
enum ByteOrText {
Byte(u8),
Text(String),
}
A String
in Rust contains three usize
fields: a pointer to the data, the length, and the capacity. On a 64-bit system, that's 3 × 8 = 24 bytes.
Normally you'd expect this enum to be 25 bytes (24 for String + 1 for tag). But here's what actually happens:
The magic: in a valid String
, the pointer is never null. It always points to allocated memory. Rust exploits this by using the null pointer value as a signal meaning "this is actually the Byte variant, not the Text variant."
So when you have:
Text("hello")
: pointer is non-null, so this is the String variantByte(42)
: pointer is null, so this is the Byte variant, and the actual42
value is stored in the length field
The tag disappeared entirely. Rust encoded the "which variant" information inside the existing data, using a bit pattern that would otherwise be invalid. The enum takes no more memory than a plain String
would.
This optimization happens automatically whenever Rust finds "impossible" bit patterns it can repurpose. You get the benefits without changing your code.
Here's proof straight from the compiler.
use std::mem::size_of;
enum Simple {
A,
B,
}
enum SingleOrPair {
One(u8),
Pair(u8, u8),
}
enum ByteOrText {
Byte(u8),
Text(String),
}
fn main() {
println!("Size of Simple: {} bytes", size_of::<Simple>());
println!("Size of SingleOrPair: {} bytes", size_of::<SingleOrPair>());
println!("Size of ByteOrText: {} bytes", size_of::<ByteOrText>());
}
These memory optimizations aren't just compiler tricks - they're what make Rust's powerful enum features practical for systems programming. When you can encode rich type information with minimal overhead, you get safety without sacrificing performance.
Key takeaway
Rust enums go far beyond the enums you may know from other languages. They carry data, enforce exhaustive handling, and make invalid states impossible. At the same time, their memory layout is compact and efficient, thanks to compiler tricks like niche-filling.
You already rely on enums every day through
Option
andResult
. But the real magic happens when you use them in your own types: modeling states, encoding invariants, and letting the compiler protect you from whole categories of bugs.
If you made it this far, you're probably as obsessed with understanding how things really work as I am. I'm Cuong, and I write about Rust and programming. If you share the same passion, I'd love to connect with you—feel free to reach out on X, LinkedIn , or subscribe to my blog (substack, medium) to keep pushing the boundaries together!