Variants Are Constructors: Modeling IP Addresses and Events
A struct groups fields that appear together. An enum models a value that is one of several shapes — and Rust's enums are full algebraic data types, not the integer aliases that C and older languages offer. The standard example from the documentation is worth typing out yourself:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
Each variant is a namespaced constructor: V4 and V6 live under IpAddr, so writing bare V4(127, 0, 0, 1) is an error — you always qualify with the enum name. This is why Some(5) reads the way it does: Some is a variant of the Option<T> enum, and the call constructs an Option<i32> holding the value 5. Once you internalize that Some is just a constructor path, Option and Result stop being special cases and become ordinary enums you already know how to use.
Payload flexibility is the headline feature. Different variants may hold different types — tuples, structs, references, other enums, or nothing at all:
enum Event<'a> {
Connected { peer: &'a str }, // named fields, like a struct variant
Data(Vec<u8>), // tuple variant
Disconnect(u8), // single-value tuple variant
Ping, // unit variant: no payload
}
Struct variants (Connected { peer }) are the same idea as named-field structs, and every method you will write on enums treats them that way. Because variants are constructors, they work in pattern position too — Event::Data(bytes) both builds a value (expression) and describes one (pattern), which is the symmetry that makes match Expressions feel natural.
Enums take #[derive(...)] and impl blocks exactly like structs:
#[derive(Debug, Clone, PartialEq)]
enum Status { Ready, Degraded(u8), Down }
impl Status {
fn is_healthy(&self) -> bool {
matches!(self, Status::Ready)
}
}
assert!(!Status::Degraded(3).is_healthy());
matches! is a macro built on patterns that answers "does this value match the shape?" without binding anything — handy in conditions before you learn full pattern syntax. One naming convention to adopt immediately: enum type names are singular (Status, not Statuses) and variant names describe states or events (Ready, Data), because you will write Status::Ready at every use site.
The real payoff shows up when states interact. Consider an order lifecycle that would sprawl across four booleans in a looser language:
enum Payment { Pending, Charged { txn: String }, Refunded(String) }
enum Shipment { NotShipped, Packed, Shipped { tracking: String }, Delivered }
struct Order<'a> {
id: &'a str,
payment: Payment,
shipment: Shipment,
}
impl Order<'_> {
fn can_cancel(&self) -> bool {
matches!(self.payment, Payment::Pending)
&& matches!(self.shipment, Shipment::NotShipped | Shipment::Packed)
}
}
Enums nest naturally — Order holds two enums, and each enum's payload could hold another — so the type system mirrors the domain's structure instead of documenting it in comments. Impossible combinations become unrepresentable: there is no ChargeRefundedButNeverCharged state, because Payment admits exactly the variants you declared. That property, making illegal states unrepresentable, is the phrase interviewers listen for when they ask why you would reach for an enum over a pile of bools and Options.
Heterogeneous collections follow the same logic: a Vec<Event> holds Ping and Data(..) values side by side because they share one type, which is exactly how event loops, protocol parsers, and UI message queues are built in real Rust codebases.
Size of an Enum: Largest Variant Plus a Discriminant
An enum value must hold any of its variants, so its size is the size of the largest variant plus enough room to record which variant is active. That record is the discriminant — an integer tag the compiler stores alongside the payload and checks during pattern matching.
enum Message {
Quit, // no payload
Move { x: i32, y: i32 }, // 8 bytes of payload
Write(String), // a String: 24 bytes on 64-bit
}
assert_eq!(std::mem::size_of::<Message>(), 32); // payload 24 + tag 8, aligned
The number is not something to memorize — it is something to measure with size_of when layout matters — but the model is: largest variant, plus tag, rounded for alignment. For network protocols, FFI, or #[repr(C)] structs you can pin the tag down with #[repr(u8)] on the enum, which makes the layout a documented contract instead of a compiler choice.
The tag is also where Rust's standard library hides an optimization you use without knowing it. Many types have invalid bit patterns the compiler can borrow as the tag — references can never be null, Box is never null, NonZeroU32 is never zero — so Option<T> can store its None marker inside the unused pattern of T:
assert_eq!(std::mem::size_of::<Option<u8>>(), 2); // tag needed: u8 has no spare pattern
assert_eq!(std::mem::size_of::<Option<&u64>>(), 8); // null doubles as None: free
assert_eq!(std::mem::size_of::<Option<Box<u64>>>(), 8); // same trick with Box
This is the niche optimization, and it explains why idiomatic Rust can afford Option everywhere: for references, pointers, and NonZero* integers, wrapping in Option costs zero bytes. It also explains the occasional surprise — Option<MyBigEnum> may add only one byte if the big enum itself has a spare discriminant, while Option<(u8, u8)> costs a third byte on top of the pair.
Three places where the layout stops being an implementation detail. FFI and interop: passing an enum to C code needs a declared layout, so #[repr(u8)] or #[repr(C)] turns "whatever the compiler chose" into a contract your C header can match. Serialization: formats like bincode mirror in-memory layout, so a #[repr] decision becomes a wire-format decision — pin it deliberately or use a real serializer that does not depend on layout. Memory-heavy containers: a million-element Vec of a fat enum pays the largest variant for every element, even the unit variants. If one variant is enormous and rare, box it:
enum Record {
Tiny(u16),
Huge(Box<BigPayload>), // 8 bytes instead of sizeof::<BigPayload>
}
The Box costs one allocation only when Huge is constructed, and every Record shrinks to tag-plus-payload size.
One more distinction worth having ready: discriminant versus equality. mem::discriminant(&v) compares only which variant is active, ignoring the payload, while derived PartialEq compares payload values too:
let a = Record::Tiny(7);
let b = Record::Tiny(9);
assert_ne!(a, b); // payloads differ, so the values differ
assert!(std::mem::discriminant(&a) == std::mem::discriminant(&b)); // same variant
Derived equality is safe around padding — the compiler compares fields, not raw bytes — so enums can be #[derive(PartialEq, Eq, Hash)] and used as HashMap keys without any of the stale-padding hazards that raw byte comparison would bring.
For interviewers, the take-away sentence is: "an enum packs its largest variant with a discriminant, and the compiler reuses unused bit patterns as that discriminant whenever possible, so Option<&T> is pointer-sized." Add that layout is observable with size_of and controllable with repr, and you have covered the question.
C-like Enums: Discriminants, `as` Casts, and Checked Conversions
An enum with no payloads is just a set of named discriminants — the familiar C enum — and Rust gives it integer semantics you can use deliberately:
#[repr(u8)]
enum Opcode {
Nop = 0,
Read = 1,
Write = 2,
Seek = 10,
}
assert_eq!(Opcode::Seek as u8, 10);
assert_eq!(std::mem::discriminant(&Opcode::Read), std::mem::discriminant(&Opcode::Read));
Without explicit values, discriminants start at 0 and increment per variant. The as cast to an integer works only for data-free enums, and the #[repr(u8)] locks the exact encoding — the combination is what makes plain enums usable for wire protocols, opcode tables, and array indexing:
const TABLE: [&str; 3] = ["nop", "read", "write"];
let op = Opcode::Read;
println!("{}", TABLE[op as usize]); // discriminant as an index
That pattern — enum for the programmer, integer on the wire, as to convert — is the idiomatic replacement for #define-style constants, and it is strictly better: the type system still knows the set of valid values inside your code.
The reverse direction is where caution is required. Casting an integer back to an enum with as is accepted for data-free enums, but it never validates the value, and the result is unspecified when the number is not a declared discriminant:
let op = 7u8 as Opcode; // compiles — and `op` is now an Opcode that was never declared
For anything crossing a trust boundary — a byte from a socket, a field from a file — use the checked conversion instead:
use std::convert::TryFrom;
let op = Opcode::try_from(2u8); // Result<Opcode, TryFromIntError>
assert!(matches!(op, Ok(Opcode::Write)));
assert!(Opcode::try_from(7u8).is_err()); // rejected loudly, at the edge
The rule to carry into production code: convert at the boundary with try_from, work with the enum internally, and convert out with as when you control the value. That way no unchecked integer ever reaches a match arm, and — as the next topic covers — the exhaustiveness checker protects every path the enum can take.
Two follow-on patterns round out the C-like corner. Bit sets combine discriminants with shifts, and validation still belongs at the edge:
#[repr(u8)]
#[derive(Clone, Copy, PartialEq)]
enum Permission { Read = 1, Write = 2, Execute = 4 }
fn parse(bits: u8) -> Vec<Permission> {
[Permission::Read, Permission::Write, Permission::Execute]
.into_iter()
.filter(|p| bits & (*p as u8) != 0)
.collect()
}
Here the enum doubles as documentation for each bit's meaning, while the raw u8 carries the combination — a common shape for flag words in file formats and config values. Explicit discriminants also exist for enums with payloads (stable since Rust 1.66, and they require a primitive representation such as #[repr(u8)]), which is how on-disk record tags stay stable while payloads evolve.
That last point deserves emphasis, because it is a design question, not a syntax one: variants are a public contract. Adding a variant to a public enum breaks every downstream match that relied on exhaustiveness, which is precisely why the compiler's non-exhaustiveness checking is a feature rather than an obstacle — and why the standard library marks evolving types #[non_exhaustive], forcing downstream code to include a wildcard arm from day one. When you design an enum that leaves your crate, decide deliberately whether callers may rely on handling exactly today's variants, or whether you are reserving the right to grow.
Quiz
1. Why must `IpAddr::V4(127, 0, 0, 1)` be written with the `IpAddr::` prefix instead of bare `V4(127, 0, 0, 1)`?
2. What determines the memory size of an enum value?
3. Why does `size_of::<Option<&u64>>()` equal 8 while `size_of::<Option<u8>>()` equals 2?
4. A byte arrives from a network socket and must become an `Opcode`. What is the correct approach?
Flashcards
Question
What is an enum variant, and how do you construct one?
Click to reveal answer
Answer
A variant is a namespaced constructor owned by its enum: `IpAddr::V4(127, 0, 0, 1)`, `Some(5)`, `Status::Ready`. Different variants may carry different payload types — tuples, named fields, other enums, or nothing — and the same syntax works as a pattern in `match` and `if let`.
Question
What is the layout of an enum value, and what is a discriminant?
Click to reveal answer
Answer
An enum value stores its largest variant's payload plus a discriminant — an integer tag identifying the active variant — with padding for alignment. `#[repr(u8)]` pins the tag's type for FFI or wire formats; otherwise layout is the compiler's choice, observable via `size_of`.
Question
What is the niche optimization, with one concrete example?
Click to reveal answer
Answer
The compiler reuses an impossible bit pattern of the payload as the discriminant. Example: `Option<&u64>` is 8 bytes, not 16, because a null pointer can never be a valid reference and therefore encodes `None`. `Option<u8>` stays 2 bytes since `u8` has no spare pattern.
Question
When can an enum be cast to an integer with `as`, and what does the reverse cast guarantee?
Click to reveal answer
Answer
Only data-free (C-like) enums support `as` casts to integers; the value is the declared or sequential discriminant, often pinned by `#[repr(u8)]`. Casting an integer back with `as` never validates — an undeclared number yields an unspecified variant — so untrusted input must go through `TryFrom`, which returns `Result`.
Question
How do you attach methods and derives to an enum?
Click to reveal answer
Answer
Identically to structs: `#[derive(Debug, Clone, PartialEq)]` on the definition and `impl Status { fn is_healthy(&self) -> bool { ... } }` for methods; associated functions without `self` work too. Inside the body you can match on `self` to handle each variant's payload.
Revision Notes
Key Takeaways
- 1. Variants are namespaced constructors — `Some(5)` and `IpAddr::V4(..)` are constructor calls, which is why `Option` needs no special syntax to build
- 2. An enum occupies its largest variant plus a discriminant tag; nothing is boxed automatically
- 3. Niche optimization makes `Option<&T>` and `Option<Box<T>>` pointer-sized, so optionals are cheap to use everywhere
- 4. Data-free enums expose their discriminants through `as` casts, making them the idiomatic replacement for C constants and opcode tables
- 5. Integer-to-enum conversion must be checked at trust boundaries with `try_from`; `as` never validates
Interview Tips
- • "How is a Rust enum different from a C enum?" — variants can carry typed payloads, they are namespaced constructors usable in patterns, and the layout is a payload plus a discriminant rather than a bare int
- • "What is the size of an `Option<&u64>`?" — 8 bytes: the null niche encodes `None`; contrast with `Option<u8>` at 2 bytes to show you understand when niches exist
- • "Why not cast a socket byte directly to your enum type?" — `as` skips validation and yields an unspecified variant for undeclared tags; `try_from` moves the check to the boundary
- • "Explain the discriminant" — the hidden integer tag that records the active variant and gets reused as a niche for `Option`; mention `#[repr(u8)]` for controlling it in wire and FFI code
Cheat Sheet
Enumerations with Data
Declaration and construction:
enum Event<'a> {
Connected { peer: &'a str }, // struct variant
Data(Vec<u8>), // tuple variant
Ping, // unit variant
}
let e = Event::Data(vec![1, 2]); // variants are namespaced constructors
Layout model: largest variant payload + discriminant tag, aligned. Measure with size_of, pin with #[repr(u8)].
Niche optimization: impossible bit patterns double as the tag — Option<&T>/Option<Box<T>> are pointer-sized; Option<u8> is 2 bytes.
Methods & derives: same as structs — #[derive(Debug, Clone)] and impl Event { ... } both work.
C-like enums:
| Task | Tool |
|---|---|
| named protocol constants | enum Op { Read = 1, Write = 2 } + #[repr(u8)] |
| enum → integer | op as usize (data-free enums only) |
| integer → enum, trusted | as cast |
| integer → enum, untrusted | Op::try_from(n) → Result |
Rule: convert at the boundary with try_from, use the enum internally, cast out with as when the value is known valid.