One of the things that took me longest to fully appreciate about Rust is how it handles data that isn’t uniform — data where a value might be “one of several different shapes.” Most of us come from a background where arrays and lists hold one type of thing, and when we need to represent “this OR that,” we reach for inheritance, unions, or just any/object types and hope for the best at runtime. Rust takes a completely different, much safer approach through enums, tuples, and pattern matching. In this article, I’ll walk through how I use these three tools together to model heterogeneous data — data of genuinely different shapes and types — in a way that the compiler can fully verify at compile time.
Why “Heterogeneous” Is Tricky in a Statically Typed Language
In a statically typed language, every variable has one fixed type, and every element of a collection like Vec<T> must share that same type T. So how do you represent, say, a single field in a spreadsheet cell that could be a number, text, or a boolean? Or an HTTP response that could succeed with data or fail with an error? This is exactly the problem enums solve in Rust — they let you define a type that can be one of several distinct variants, each potentially carrying its own different data.
Tuples: Grouping Different Types Together
Let’s start with the simplest heterogeneous structure: the tuple. A tuple groups values of different types into one compound value.
fn main() {
let person: (String, u8, bool) = (String::from("Ayesha"), 29, true);
println!("Name: {}", person.0);
println!("Age: {}", person.1);
println!("Is active: {}", person.2);
}
Output:
Name: Ayesha
Age: 29
Is active: true
Here, person is a single tuple value containing a String, a u8, and a bool — three genuinely different types living together in one variable. You access elements with dot notation and a zero-based index: .0, .1, .2.
Tuples also support destructuring, which I use constantly:
fn main() {
let coordinates = (10, 20.5, "origin-offset");
let (x, y, label) = coordinates;
println!("x = {x}, y = {y}, label = {label}");
}
Output:
x = 10, y = 20.5, label = origin-offset
Tuples are great for small, fixed-size, ad hoc groupings — especially returning multiple values from a function — but they’re limited: there’s no name attached to each position beyond its index, and you can’t have a Vec of tuples where different elements have different tuple layouts. That’s where enums come in.
Enums: The Real Workhorse of Heterogeneous Data
An enum in Rust lets you define a type by enumerating its possible variants, and — this is the part that makes Rust’s enums dramatically more powerful than enums in languages like C or Java — each variant can hold its own different data.
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle { base: f64, height: f64 },
}
fn main() {
let shapes = vec![
Shape::Circle(3.0),
Shape::Rectangle(4.0, 5.0),
Shape::Triangle { base: 6.0, height: 2.0 },
];
for shape in &shapes {
let area = calculate_area(shape);
println!("Area: {area:.2}");
}
}
fn calculate_area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
}
}
Output:
Area: 28.27
Area: 20.00
Area: 6.00
Look at what just happened: shapes is a single Vec<Shape>, yet each element holds fundamentally different data — a lone f64 for Circle, two f64s for Rectangle, and a named struct-like payload for Triangle. This is genuine heterogeneous data, stored safely and uniformly, because from the compiler’s point of view every element is still just one type: Shape. The “heterogeneity” is captured inside the enum’s variants rather than at the collection level, and the compiler tracks exactly what data each variant carries.
Pattern Matching: Unpacking Heterogeneous Data Safely
The match expression I used above is how you get data back out of an enum, and it’s one of Rust’s most powerful features. Unlike a switch statement in C-family languages, Rust’s match is exhaustive — the compiler forces you to handle every possible variant, or explicitly acknowledge you’re ignoring some with a _ catch-all.
enum WebEvent {
PageLoad,
KeyPress(char),
Click { x: i64, y: i64 },
}
fn inspect(event: WebEvent) {
match event {
WebEvent::PageLoad => println!("Page loaded"),
WebEvent::KeyPress(c) => println!("Key pressed: '{c}'"),
WebEvent::Click { x, y } => println!("Clicked at ({x}, {y})"),
}
}
fn main() {
inspect(WebEvent::PageLoad);
inspect(WebEvent::KeyPress('a'));
inspect(WebEvent::Click { x: 100, y: 250 });
}
Output:
Page loaded
Key pressed: 'a'
Clicked at (100, 250)
If I add a new variant to WebEvent later — say WebEvent::Scroll(i64) — and forget to handle it in inspect‘s match, the code simply won’t compile until I do. This is a massive safety net in real projects: you cannot accidentally forget to handle a new case the way you easily can with an if/else if chain or a non-exhaustive switch.
Option<T> and Result<T, E>: Enums You Already Use
If you’ve written any Rust at all, you’ve already used heterogeneous enums without necessarily thinking of them that way. Option<T> represents “a value, or nothing”:
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Bilal"))
} else {
None
}
}
fn main() {
match find_user(1) {
Some(name) => println!("Found user: {name}"),
None => println!("User not found"),
}
match find_user(99) {
Some(name) => println!("Found user: {name}"),
None => println!("User not found"),
}
}
Output:
Found user: Bilal
User not found
Result<T, E> represents “success with a value, or failure with an error,” and is Rust’s primary mechanism for error handling instead of exceptions:
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}"),
}
match divide(10.0, 0.0) {
Ok(result) => println!("Result: {result}"),
Err(e) => println!("Error: {e}"),
}
}
Output:
Result: 5
Error: division by zero
Both Option<T> and Result<T, E> are ordinary enums defined in the standard library — there’s no special-case language magic here, just the same enum-plus-pattern-matching machinery you can use for your own types.
if let and while let: Lighter-Weight Pattern Matching
Sometimes I only care about one variant and want to ignore the rest without writing a full match. if let handles that:
fn main() {
let config_value: Option<u8> = Some(3);
if let Some(value) = config_value {
println!("Config value is: {value}");
} else {
println!("No config value set");
}
}
Output:
Config value is: 3
And while let is handy for draining a stack or queue:
fn main() {
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {top}");
}
}
Output:
Popped: 3
Popped: 2
Popped: 1
Internal Working: How Rust Stores Enum Data in Memory
It’s worth understanding, at least at a high level, how the compiler represents these heterogeneous enums in memory, because it explains both the safety and the efficiency you get. For an enum like Shape from earlier, Rust allocates enough space to hold the largest variant, plus a small discriminant (tag) that records which variant is actually active. When you match on the enum, the compiler generates code that reads the discriminant first and then interprets the remaining bytes according to that variant’s layout. This is sometimes called a “tagged union,” and it’s why match is both memory-safe (you can never accidentally read a Rectangle‘s data as if it were a Circle‘s) and fast (it’s just a jump table based on the tag, not runtime type inspection or virtual dispatch through a vtable). For enums where every variant has a unique, non-overlapping bit pattern with certain non-nullable types (like references), Rust can even apply “niche optimization” so that Option<&T> takes up exactly as much space as &T alone, with None represented by the null pointer pattern — a nice example of Rust squeezing zero-cost abstraction out of what looks like extra structure.
Ownership and Borrowing With Pattern Matching
Pattern matching interacts with ownership in ways you need to be deliberate about. Matching on an owned enum by value moves it (or its inner data) unless you match by reference:
enum Message {
Text(String),
Quit,
}
fn handle_by_ref(msg: &Message) {
match msg {
Message::Text(s) => println!("Text: {s}"), // s is &String here
Message::Quit => println!("Quit"),
}
}
fn main() {
let m = Message::Text(String::from("hello"));
handle_by_ref(&m);
// m is still valid here because we only borrowed it
if let Message::Text(s) = &m {
println!("Still have: {s}");
}
}
Output:
Text: hello
Still have: hello
By matching on &Message instead of Message, the bindings inside each arm (like s in Message::Text(s)) become references rather than owned values, so m remains valid for further use in main. This pattern — borrow, then match on the reference — is extremely common in idiomatic Rust and avoids unnecessary clones.
Real-World Applications
I reach for enums plus pattern matching constantly in real projects:
- Parsing and ASTs — representing tokens or syntax tree nodes, where a
Tokenmight beNumber(f64),Identifier(String), orOperator(char). - State machines — modeling application or connection state, e.g.,
ConnectionState::Disconnected,Connecting { attempt: u8 },Connected { session_id: String }. - API responses and error handling —
Result<T, E>with custom error enums covering every failure mode a function can produce. - Command-line argument or configuration handling — representing a value that could be a flag, a string, or a number depending on context.
Best Practices and Idiomatic Patterns
- Model your domain with enums instead of booleans or magic strings. An enum with named variants documents intent far better than a
status: Stringfield that could be anything. - Match on references when you don’t need ownership, to avoid unnecessary moves or clones.
- Use
if letfor the common single-variant case, and reserve fullmatchfor when you genuinely need to handle multiple variants. - Lean on exhaustiveness checking — resist the urge to add a
_ => {}catch-all just to silence the compiler unless you truly intend to ignore future variants.
Common Mistakes and Debugging Tips
A frequent early mistake is trying to match on an owned value you still need afterward, which triggers:
error[E0382]: use of partially moved value
The fix is almost always to match on a reference (match &value or match value.as_ref()) instead of moving the value.
Another common stumble is a non-exhaustive match, which the compiler flags with:
error[E0004]: non-exhaustive patterns
Read the error carefully — it lists exactly which variants you haven’t handled, which makes fixing it mechanical rather than mysterious.
FAQs
How is a Rust enum different from a C-style enum? A C-style enum is just a set of named integer constants. A Rust enum can attach arbitrarily different data to each variant, making it closer to what’s called a “sum type” or “tagged union” in type theory.
When should I use a tuple versus a tuple struct versus a full enum? Use a plain tuple for quick, unnamed, local groupings (like a function returning two values). Use a tuple struct (struct Point(f64, f64);) when the grouping has a clear identity and reused meaning. Use an enum when a value can genuinely be one of several distinct alternatives.
Does pattern matching have a runtime performance cost? Matching on an enum is essentially a tag check followed by a jump — comparable to a switch in C — so it’s very fast and doesn’t involve dynamic dispatch or reflection.
Can enum variants have methods? Yes — you define methods in an impl block on the enum type itself, and use match inside the method body to branch by variant.
Summary
Rust gives you three complementary tools for working with heterogeneous data: tuples for small, ad hoc, positional groupings; enums for representing “one of several distinct shapes” as a single, unified type; and pattern matching for safely and exhaustively unpacking that data. Because the compiler tracks every variant of an enum and enforces exhaustive handling wherever you match on it, you get the flexibility of representing wildly different data shapes without sacrificing the type safety and memory safety Rust is known for — and, thanks to the tagged-union representation under the hood, without paying a meaningful performance penalty either.
References
- The Rust Programming Language Book, Chapter 6: Enums and Pattern Matching — https://doc.rust-lang.org/book/ch06-00-enums.html
- The Rust Programming Language Book, Chapter 18: Patterns and Matching — https://doc.rust-lang.org/book/ch18-00-patterns.html
- The Rust Reference, Enumerations — https://doc.rust-lang.org/reference/items/enumerations.html
- Rust by Example, Enums — https://doc.rust-lang.org/rust-by-example/custom_types/enum.html
- Cargo Documentation — https://doc.rust-lang.org/cargo/