Rust enums are sum types (a.k.a. tagged unions): a value that is exactly one of a fixed set of variants, and each variant can carry its own data. Combined with match, they let the compiler prove you've handled every case. This is one of the biggest day-to-day differences from C#, so we'll go deep.
Enums with data
Each variant can have no data, a tuple of fields, or named (struct-like) fields.
enum Message {
Quit, // no data
Move { x: i32, y: i32 }, // named fields, like a struct
Write(String), // one unnamed field (tuple variant)
ChangeColor(i32, i32, i32), // three unnamed fields
}Construct them by naming the variant:
let msgs = vec![
Message::Quit,
Message::Move { x: 10, y: 20 },
Message::Write(String::from("hello")),
Message::ChangeColor(255, 0, 0),
];In C#: The closest thing is an F# discriminated union, or a sealed/abstract class hierarchy where each subclass is a variant. C# enum is just a named integer — it cannot carry per-variant data, and nothing forces you to handle every case. A Rust enum is one type whose value is one variant at a time, and the payload lives inside the value with no heap allocation or subclassing required.
Methods on enums
Enums can have methods via impl, just like structs:
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::ChangeColor(r, g, b) => format!("color #{r:02x}{g:02x}{b:02x}"),
}
}
}In C#: Like adding methods to a base class and switching on the runtime type inside — but here it's one type and one exhaustive match, checked at compile time.
Option: no more null
Option<T> models "a value that might be absent": either Some(T) or None. Rust has no null for ordinary values, so the type tells you when something can be missing and the compiler forces you to deal with it.
fn find_index(v: &[i32], x: i32) -> Option<usize> {
for (i, &n) in v.iter().enumerate() {
if n == x {
return Some(i);
}
}
None
}You rarely reach into an Option by hand — the combinators are ergonomic:
let opt: Option<i32> = Some(5);
opt.map(|n| n * 2); // Some(10) — transform the inner value
opt.and_then(|n| checked(n)); // chain another Option-returning call
opt.unwrap_or(0); // 5, or 0 if None — supply a default
opt.unwrap_or_else(|| slow()); // like unwrap_or but the default is lazy
opt.is_some(); // trueIn C#: Like nullable types (int?, string?) plus the null-conditional/?? operators — but in Rust it's a real value in the type system, not a compiler annotation you can ignore. None is a value you can store, return, and pattern-match; there's no NullReferenceException waiting for you.
Result: errors as values
Result<T, E> is either Ok(T) (success, carrying the value) or Err(E) (failure, carrying an error). Fallible functions return a Result instead of throwing.
fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
s.parse() // parse() already returns Result
}The ? operator
Writing a match to unwrap every Result gets noisy. The ? operator does it for you: if the value is Ok, it unwraps it; if it's Err, it returns that error from the current function immediately.
fn sum_two(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
let x = a.parse::<i32>()?; // on Err, return Err(..) from sum_two
let y = b.parse::<i32>()?;
Ok(x + y)
}? also works on Option (returning None early). The function's return type must be compatible (Result/Option respectively).
In C#: Result replaces throwing exceptions for expected failures (parse errors, missing files, validation). ? is roughly "early-return on failure" — a bit like exception propagation, except it's explicit in the signature and visible at every call site. You can't forget to handle it: an unused Result triggers a compiler warning.
match: exhaustive by construction
match compares a value against patterns, top to bottom, and runs the first arm that fits. It is an expression (it produces a value) and it must be exhaustive — every possible case handled, or it won't compile.
let opt: Option<i32> = Some(5);
let label = match opt {
Some(n) => format!("value is {n}"),
None => "no value".to_string(),
};In C#: Like a switch expression, but the compiler enforces exhaustiveness on enums and there is no fall-through. Forget a variant and it's a compile error, not a silent bug.
The catch-all: _ and named binding
Use _ to match anything you don't care about, or a name to match anything and capture it:
let n = 7;
match n {
1 => println!("one"),
2 => println!("two"),
other => println!("something else: {other}"), // binds the value
// `_ => ...` would match without binding
}Multiple patterns, ranges, and guards
let n = 5;
match n {
0 => println!("zero"),
1 | 2 | 3 => println!("small"), // OR-pattern: any of these
4..=9 => println!("medium"), // inclusive range
x if x % 2 == 0 => println!("big even"), // guard: extra boolean condition
_ => println!("big odd"),
}A guard (if <cond>) is checked after the pattern matches; if it's false, matching continues to the next arm.
Destructuring in patterns
Patterns can reach into structures — enums, tuples, structs, and nested combinations — binding the pieces you name.
match msg {
Message::Quit => {}
Message::Move { x, y } => println!("move to {x}, {y}"),
Message::Write(s) => println!("write: {s}"),
Message::ChangeColor(r, g, b) => println!("color {r}, {g}, {b}"),
}Structs and tuples destructure too, and it nests arbitrarily:
struct Point { x: i32, y: i32 }
let ((a, b), Point { x, y }) = ((1, 2), Point { x: 3, y: 4 });
// a=1, b=2, x=3, y=4
let point = Point { x: 0, y: 7 };
match point {
Point { x: 0, y } => println!("on the y-axis at {y}"), // match a literal + bind
Point { x, y: 0 } => println!("on the x-axis at {x}"),
Point { x, y } => println!("at ({x}, {y})"),
}@ bindings: match a pattern and keep the value
Use name @ pattern to test a value against a pattern while also binding the whole thing:
let id = 5;
match id {
n @ 1..=9 => println!("single digit: {n}"), // n is bound only if in 1..=9
n => println!("other: {n}"),
}In C#: Similar to C# 8+ pattern matching in switch expressions (property patterns, relational patterns, when guards). Rust's version is more uniform — the same pattern syntax works in match, if let, let, and function parameters — and it's exhaustive.
if let: one case, less ceremony
When you only care about a single variant, if let is a lighter match:
if let Some(n) = opt {
println!("got {n}");
} else {
println!("nothing"); // optional else
}while let: loop until the pattern stops matching
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() { // stops when pop() returns None
println!("{top}"); // 3, 2, 1
}let else: bind or bail out
let ... else binds when the pattern matches and otherwise runs a block that must diverge (return, break, panic!, etc.). It's great for early returns without nesting:
fn first_char_upper(s: &str) -> Option<char> {
let Some(c) = s.chars().next() else {
return None; // no first char -> leave early
};
Some(c.to_ascii_uppercase()) // c is in scope for the rest of the function
}In C#: if let ≈ if (x is SomeType n) { ... }. while let ≈ looping on TryTake(out var item). let else ≈ a guard clause: if (!TryGet(out var c)) return null;.
Matching references
When you match on a &T (a borrow), Rust's default binding modes usually let you write the pattern as if you had the value, binding the inner fields by reference automatically:
let maybe = Some(String::from("hi"));
match &maybe {
Some(s) => println!("{} chars", s.len()), // s is &String, maybe not moved
None => {}
}
println!("{maybe:?}"); // still usable — we only borrowedIf you match on maybe directly (by value), a Some(s) arm would move the String out, and you couldn't use maybe afterward. Matching on &maybe borrows instead. (You may still occasionally see the explicit ref keyword in older code; default binding modes make it rarely necessary now.)
In C#: There's no direct analogue because C# reference types are always references. The takeaway: in Rust, how you match (by value vs. by reference) decides whether the data is moved out or just borrowed — mind this when the payload is a non-Copy type like String.
Key takeaway
- Enums are sum types: one value, one variant, each variant with its own optional payload — and they can have methods.
Option<T>replaces null andResult<T, E>replaces exceptions; the?operator propagates the "absent"/"error" case with minimal noise.matchis an exhaustive expression: the compiler guarantees every variant is handled. Use_/named catch-alls, OR-patterns, ranges, guards, and@bindings to express exactly the cases you mean.- Reach for
if let,while let, andlet elsewhen a fullmatchis overkill. - Match on
&valueto borrow rather than move non-Copypayloads.