Generic Enums
18 min read
Enums that are parameterized over a type, like TypeScript’s discriminated unions with generics. This is where Rust’s two most important types — Option<T> and Result<T, E> — come from, so understanding generic enums is understanding the heart of the standard library.
Quick Overview
Section titled “Quick Overview”A generic enum is an enum with one or more type parameters, so the same shape (Some/None, Ok/Err, a tree node, a cache slot) can hold any type you choose. If you have ever written a TypeScript discriminated union like type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }, you already understand the idea. The difference is that Rust bakes this pattern into the language, makes the compiler force you to handle every case, and generates a separate, fully-optimized copy of the enum for each concrete type you use (monomorphization).
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In TypeScript you reach for a discriminated union (also called a tagged union) when a value can be in one of several shapes. Generics let you reuse that shape across types. A common one is a hand-rolled Result to avoid throwing exceptions:
// A generic "result" union: success carries a T, failure carries an E.type Result<T, E> = | { kind: "ok"; value: T } | { kind: "err"; error: E };
interface User { id: number; name: string;}
interface ApiError { status: number; message: string;}
function parseUser(json: string): Result<User, ApiError> { try { const data = JSON.parse(json) as User; return { kind: "ok", value: data }; } catch { return { kind: "err", error: { status: 400, message: "invalid JSON" } }; }}
// The consumer must check the discriminant before touching the payload.const result = parseUser('{"id":1,"name":"Ada"}');if (result.kind === "ok") { console.log(`Loaded ${result.value.name}`); // Loaded Ada} else { console.log(`Failed: ${result.error.message}`);}TypeScript’s union is structural and runtime-erased: the kind field is a real property you check at runtime, and the <T, E> type parameters exist only at compile time — they vanish in the emitted JavaScript.
Note: TypeScript and JavaScript also have a built-in optional pattern:
T | undefined(orT | null). That is the closest analogue to Rust’sOption<T>, but as you will see, the Rust version forces you to handle the absent case.
Rust Equivalent
Section titled “Rust Equivalent”Rust spells the same idea with enum plus angle-bracket type parameters. Here is a generic Either<L, R> and a generic Cache<T> with methods:
// A generic enum with two type parameters.#[derive(Debug)]enum Either<L, R> { Left(L), Right(R),}
// A generic enum with one type parameter, plus an inherent impl.#[derive(Debug)]enum Cache<T> { Empty, Loaded(T),}
impl<T> Cache<T> { fn get(&self) -> Option<&T> { match self { Cache::Empty => None, Cache::Loaded(value) => Some(value), } }
fn is_loaded(&self) -> bool { matches!(self, Cache::Loaded(_)) }}
fn main() { let a: Either<i32, String> = Either::Left(42); let b: Either<i32, String> = Either::Right(String::from("oops")); println!("{:?} {:?}", a, b); // Left(42) Right("oops")
let c: Cache<String> = Cache::Loaded(String::from("data")); println!("loaded? {} value = {:?}", c.is_loaded(), c.get()); // loaded? true value = Some("data")
let empty: Cache<String> = Cache::Empty; println!("loaded? {} value = {:?}", empty.is_loaded(), empty.get()); // loaded? false value = None}Real output:
Left(42) Right("oops")loaded? true value = Some("data")loaded? false value = NoneThe standard library’s Option<T> and Result<T, E> are exactly this pattern — generic enums that you will use constantly:
// These are (essentially) how the standard library defines them:enum Option<T> { None, Some(T),}
enum Result<T, E> { Ok(T), Err(E),}You do not need to define Option or Result yourself — they are in the prelude, so Some, None, Ok, and Err are always in scope.
Detailed Explanation
Section titled “Detailed Explanation”Declaring the type parameter
Section titled “Declaring the type parameter”enum Either<L, R> { Left(L), Right(R),}The <L, R> after the enum name introduces two type parameters. Each variant can use them: Left holds an L, Right holds an R. The names are conventional — single uppercase letters like T, E, K, V, L, R — but you can use a descriptive name like Payload if it reads better. Unlike TypeScript, where you write type Either<L, R> = ..., Rust uses the enum keyword and each variant is a real constructor (Either::Left, Either::Right).
Methods need their own <T>
Section titled “Methods need their own <T>”impl<T> Cache<T> { fn is_loaded(&self) -> bool { /* ... */ }}The impl<T> reads as: “for every type T, here are methods on Cache<T>.” The <T> right after impl declares the parameter; the <T> in Cache<T> uses it. Forgetting the first one is a classic beginner error (see Common Pitfalls). This is covered in depth in generic-structs.md, and the same rule applies to enums.
Pattern matching destructures the payload
Section titled “Pattern matching destructures the payload”match self { Cache::Empty => None, Cache::Loaded(value) => Some(value),}match is how you safely get the inner value out. In the Cache::Loaded(value) arm, value is bound to the &T inside (because we matched on &self). This is the same idea as checking result.kind === "ok" in TypeScript and then reading result.value, except the compiler guarantees you handled every variant. If you add a third variant later, every match that does not handle it stops compiling — a refactoring superpower TypeScript’s switch cannot match unless you opt in with never checks. See ../04-control-flow/README.md for more on match and if let.
Monomorphization: one copy per concrete type
Section titled “Monomorphization: one copy per concrete type”This is the deepest difference from TypeScript. When you write Cache<String> and Cache<i32>, the Rust compiler generates two separate, fully-specialized enums — as if you had hand-written CacheString and CacheI32. There is no boxing, no tag-dispatch overhead, and the layout is optimal for each type. You can observe the distinct layouts:
use std::mem::size_of;
#[derive(Debug)]enum Slot<T> { Empty, Full(T),}
fn main() { println!("Slot<u8> = {} bytes", size_of::<Slot<u8>>()); println!("Slot<i64> = {} bytes", size_of::<Slot<i64>>());
let a: Slot<u8> = Slot::Full(1); let b: Slot<i64> = Slot::Empty; println!("{:?} {:?}", a, b);}Real output:
Slot<u8> = 2 bytesSlot<i64> = 16 bytesFull(1) EmptyThe two sizes are the point: Slot<u8> is 2 bytes (1 byte payload + 1 byte tag), while Slot<i64> is 16 bytes (8 bytes payload + padding + tag). TypeScript erases generics entirely, so there is only ever one runtime representation and the <T> is gone. Rust trades a little compile time and binary size for zero-cost, type-specialized code. Monomorphization is explained fully in generic-functions.md.
The ? operator works on generic enums
Section titled “The ? operator works on generic enums”Because Option<T> and Result<T, E> are generic enums with a known shape, the ? operator can short-circuit on them:
fn first_two(s: &str) -> Option<(char, char)> { let mut it = s.chars(); let a = it.next()?; // if None, return None from first_two let b = it.next()?; Some((a, b))}
fn parse_and_double(s: &str) -> Result<i32, std::num::ParseIntError> { let n: i32 = s.parse()?; // if Err, return that Err Ok(n * 2)}
fn main() { println!("{:?}", first_two("hi")); // Some(('h', 'i')) println!("{:?}", first_two("x")); // None println!("{:?}", parse_and_double("21")); // Ok(42) println!("{:?}", parse_and_double("no")); // Err(ParseIntError { kind: InvalidDigit })}Real output:
Some(('h', 'i'))NoneOk(42)Err(ParseIntError { kind: InvalidDigit })? is roughly TypeScript’s optional chaining ?. combined with early-return-on-error, but it is built on these generic enums. Error handling with Result and ? is the subject of ../08-error-handling/README.md.
Key Differences
Section titled “Key Differences”| Aspect | TypeScript discriminated union | Rust generic enum |
|---|---|---|
| Syntax | type U<T> = { kind: "a"; ... } | ... | enum U<T> { A(T), ... } |
| Discriminant | A real runtime field you choose (kind) | A hidden, compiler-managed tag |
| Generics at runtime | Erased — one shared representation | Monomorphized — one specialized copy per type |
| Exhaustiveness | Opt-in (never trick / switch default) | Enforced — non-exhaustive match is a compile error |
| Optional value | T | undefined | Option<T> (Some/None) |
| Fallible value | hand-rolled union or throw | Result<T, E> (Ok/Err) |
| Memory layout | uniform (boxed object) | sized exactly for T, with niche optimization |
Null safety is a generic enum, not a special case
Section titled “Null safety is a generic enum, not a special case”TypeScript bolts optionality onto every type with T | undefined, and strictNullChecks is what makes you check it. Rust has no null. Absence is modeled with the ordinary generic enum Option<T>, and the type system makes the absent case impossible to ignore: you cannot read the T out of an Option<T> without first dealing with None.
Niche optimization
Section titled “Niche optimization”Because the compiler controls the tag, it can be clever. For types that have an impossible bit pattern (a “niche”), Option reuses it instead of adding a separate tag. A reference or Box can never be null, so Option<&T> and Option<Box<T>> use the all-zero pointer as None and are the same size as the bare pointer:
use std::mem::size_of;
fn main() { println!("&i32 = {} bytes", size_of::<&i32>()); println!("Option<&i32> = {} bytes", size_of::<Option<&i32>>()); println!("Box<i32> = {} bytes", size_of::<Box<i32>>()); println!("Option<Box<i32>> = {} bytes", size_of::<Option<Box<i32>>>()); println!("i32 = {} bytes", size_of::<i32>()); println!("Option<i32> = {} bytes", size_of::<Option<i32>>());}Real output:
&i32 = 8 bytesOption<&i32> = 8 bytesBox<i32> = 8 bytesOption<Box<i32>> = 8 bytesi32 = 4 bytesOption<i32> = 8 bytesOption<&i32> costs nothing extra, while Option<i32> needs a separate discriminant byte (rounded up to 8 for alignment) because every i32 bit pattern is a valid value. TypeScript’s number | undefined has no such trick — undefined is just another runtime value. Box<T> and other smart pointers are covered in ../10-smart-pointers/README.md.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Forgetting the type annotation on a bare variant
Section titled “Pitfall 1: Forgetting the type annotation on a bare variant”A variant with no payload (None, Cache::Empty) gives the compiler nothing to infer T from:
fn main() { let cache = None; // does not compile (error[E0282]: type annotations needed) println!("{:?}", cache);}Real compiler error:
error[E0282]: type annotations needed for `Option<_>` --> src/main.rs:3:9 |3 | let cache = None; // what is T? | ^^^^^ ---- type must be known at this point |help: consider giving `cache` an explicit type, where the type for type parameter `T` is specified |3 | let cache: Option<T> = None; // what is T? | +++++++++++Fix: annotate the binding (let cache: Option<i32> = None;) or use the turbofish on a function that returns it. Coming from TypeScript this surprises people, because const x = undefined is always fine there — but TypeScript widens it to undefined/any, whereas Rust refuses to guess T.
Pitfall 2: Non-exhaustive match
Section titled “Pitfall 2: Non-exhaustive match”Forgetting a variant is a hard error, not a warning:
enum Either<L, R> { Left(L), Right(R),}
fn describe(e: Either<i32, String>) -> String { match e { Either::Left(n) => format!("number {}", n), // does not compile (error[E0004]): forgot Either::Right }}Real compiler error:
error[E0004]: non-exhaustive patterns: `Either::Right(_)` not covered --> src/main.rs:7:11 |7 | match e { | ^ pattern `Either::Right(_)` not covered |note: `Either<i32, String>` defined here --> src/main.rs:1:6 |1 | enum Either<L, R> { | ^^^^^^2 | Left(L),3 | Right(R), | ----- not covered = note: the matched value is of type `Either<i32, String>`This is a feature: add a variant later and the compiler lists every place that needs updating. To intentionally ignore the rest, add _ => ....
Pitfall 3: Forgetting <T> after impl
Section titled “Pitfall 3: Forgetting <T> after impl”enum Maybe<T> { Just(T), Nothing,}
impl Maybe<T> { // does not compile (error[E0412]: cannot find type `T`) fn is_just(&self) -> bool { matches!(self, Maybe::Just(_)) }}Real compiler error:
error[E0412]: cannot find type `T` in this scope --> src/main.rs:6:12 |6 | impl Maybe<T> { // forgot the <T> after impl | ^ not found in this scope |help: you might be missing a type parameter |6 | impl<T> Maybe<T> { // forgot the <T> after impl | +++Fix: write impl<T> Maybe<T>. The first <T> declares the parameter, the second uses it.
Pitfall 4: Reaching for null/undefined habits
Section titled “Pitfall 4: Reaching for null/undefined habits”TypeScript developers often look for “the empty value.” There is no null in safe Rust. Model absence with Option<T> and a missing-but-recoverable failure with Result<T, E>. Trying to leave a field “unset” by some other means usually means you actually wanted Option<T>.
Best Practices
Section titled “Best Practices”- Reach for
Option<T>andResult<T, E>first. Do not invent your own two-state enums for “maybe a value” or “value or error” — the standard ones come with dozens of combinators (map,and_then,unwrap_or,ok_or,?) and integrate with the whole ecosystem. - Derive the traits you need.
#[derive(Debug)]is almost always worth adding so you canprintln!("{:?}", value). AddClone,PartialEq, etc. as needed. - Name your own domain enums, even when
Eitherwould work.Either<L, R>is generic and meaningless to a reader; aPayment { Card(CardInfo), Cash(Money) }enum documents intent. Use generics on enums when the container logic is reusable, not to avoid naming a domain concept. - Provide constructors and combinators in an
impl<T>block rather than forcing callers tomatcheverywhere. Amap/get/unwrap_ormethod centralizes the pattern. - Bound type parameters where the method needs it, not on the enum. Put
where T: Cloneon the specific method’simpl, so that constructing the enum stays unconstrained. See trait-bounds.md. - Box recursive variants. A self-referential enum like a tree or linked list must put the recursive part behind a pointer (
Box<Tree<T>>); otherwise it would have infinite size. See the Real-World Example below and ../10-smart-pointers/README.md.
Real-World Example
Section titled “Real-World Example”A production-flavored use of a generic enum: a Fetch<T, E> state machine, the kind you would model a data-fetching hook around (idle → loading → success/failure). It is generic so the same machine works for any payload and error type.
use std::fmt;
/// The lifecycle of an asynchronous request, generic over its payload `T`/// and error `E`. Mirrors the states a frontend data hook moves through.#[derive(Debug, Clone, PartialEq)]enum Fetch<T, E> { Idle, Loading, Success(T), Failure(E),}
impl<T, E> Fetch<T, E> { /// A request is "terminal" once it has succeeded or failed. fn is_terminal(&self) -> bool { matches!(self, Fetch::Success(_) | Fetch::Failure(_)) }
/// Transform the success payload, leaving the other states untouched. fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Fetch<U, E> { match self { Fetch::Idle => Fetch::Idle, Fetch::Loading => Fetch::Loading, Fetch::Success(value) => Fetch::Success(f(value)), Fetch::Failure(err) => Fetch::Failure(err), } }}
#[derive(Debug, Clone, PartialEq)]struct User { id: u32, name: String,}
#[derive(Debug, Clone, PartialEq)]struct ApiError { status: u16, message: String,}
impl fmt::Display for ApiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "HTTP {}: {}", self.status, self.message) }}
fn render(state: &Fetch<User, ApiError>) -> String { match state { Fetch::Idle => "Click to load".to_string(), Fetch::Loading => "Loading...".to_string(), Fetch::Success(user) => format!("Welcome, {} (#{})", user.name, user.id), Fetch::Failure(err) => format!("Error: {}", err), }}
fn main() { let mut state: Fetch<User, ApiError> = Fetch::Idle; println!("{}", render(&state)); // Click to load
state = Fetch::Loading; println!("{}", render(&state)); // Loading... println!("terminal? {}", state.is_terminal()); // false
state = Fetch::Success(User { id: 7, name: "Ada".to_string() }); println!("{}", render(&state)); // Welcome, Ada (#7) println!("terminal? {}", state.is_terminal()); // true
// Derive a display-name view without losing the state machine's shape. let display: Fetch<String, ApiError> = state.clone().map(|u| u.name.to_uppercase()); println!("{:?}", display); // Success("ADA")
let failed: Fetch<User, ApiError> = Fetch::Failure(ApiError { status: 404, message: "not found".to_string() }); println!("{}", render(&failed)); // Error: HTTP 404: not found}Real output:
Click to loadLoading...terminal? falseWelcome, Ada (#7)terminal? trueSuccess("ADA")Error: HTTP 404: not foundNote the map method’s own type parameters <U, F: FnOnce(T) -> U>: it converts a Fetch<T, E> into a Fetch<U, E> while preserving the error type — the same signature shape as Option::map and Result::map.
Further Reading
Section titled “Further Reading”Official documentation
Section titled “Official documentation”- The Rust Book — Generic Data Types (enums)
- The Rust Book — Defining an Enum (
Option) std::option::OptionAPI docsstd::result::ResultAPI docs- Rust Reference — Type layout & niche optimization
Related sections in this guide
Section titled “Related sections in this guide”- generic-functions.md — type parameters on functions, monomorphization, the turbofish
::<> - generic-structs.md — the same
<T>machinery on structs, with multiple parameters and constrained impls - trait-bounds.md — restricting a type parameter with
T: Traitandwhereclauses - traits.md — TypeScript interfaces become Rust traits
- ../04-control-flow/README.md —
match,if let, and exhaustiveness - ../08-error-handling/README.md —
Result, the?operator, and error types in depth - ../10-smart-pointers/README.md —
Box<T>for recursive enums and heap allocation - Section 09 overview
Exercises
Section titled “Exercises”Exercise 1: A generic singly linked list
Section titled “Exercise 1: A generic singly linked list”Difficulty: Easy
Objective: Define a recursive generic enum and write methods over it.
Instructions:
- Define
enum List<T> { Cons(T, Box<List<T>>), Nil }. - Add
impl<T> List<T>withnew()(returnsNil),push(self, value: T) -> Self(prepends a value), andlen(&self) -> usize(counts elements recursively). - In
main, build a list of three numbers and print its length, then build a list of&strto prove it is generic.
#[derive(Debug)]enum List<T> { Cons(T, Box<List<T>>), Nil,}
impl<T> List<T> { fn new() -> Self { /* ??? */ } fn push(self, value: T) -> Self { /* ??? */ } fn len(&self) -> usize { // TODO: match on self }}Solution
#[derive(Debug)]enum List<T> { Cons(T, Box<List<T>>), Nil,}
impl<T> List<T> { fn new() -> Self { List::Nil }
fn push(self, value: T) -> Self { // Prepend by wrapping the existing list as the tail. List::Cons(value, Box::new(self)) }
fn len(&self) -> usize { match self { List::Nil => 0, List::Cons(_, rest) => 1 + rest.len(), } }}
fn main() { let list = List::new().push(1).push(2).push(3); println!("len = {}", list.len()); // len = 3 println!("{:?}", list); // Cons(3, Cons(2, Cons(1, Nil)))
let words: List<&str> = List::new().push("a").push("b"); println!("len = {}", words.len()); // len = 2}Real output:
len = 3Cons(3, Cons(2, Cons(1, Nil)))len = 2The recursive variant must be wrapped in Box so the enum has a known, finite size.
Exercise 2: Either combinators and conversion to Result
Section titled “Exercise 2: Either combinators and conversion to Result”Difficulty: Medium
Objective: Add map-style combinators to a generic two-parameter enum and convert it into a standard Result.
Instructions:
- Define
enum Either<L, R> { Left(L), Right(R) }(deriveDebugandPartialEq). - Add
map_right<R2, F: FnOnce(R) -> R2>(self, f: F) -> Either<L, R2>that transforms only theRightpayload. - Add
into_result(self) -> Result<R, L>treatingLeftas the error andRightas success. - Demonstrate both in
main.
Solution
#[derive(Debug, PartialEq)]enum Either<L, R> { Left(L), Right(R),}
impl<L, R> Either<L, R> { fn map_right<R2, F: FnOnce(R) -> R2>(self, f: F) -> Either<L, R2> { match self { Either::Left(l) => Either::Left(l), Either::Right(r) => Either::Right(f(r)), } }
fn into_result(self) -> Result<R, L> { match self { Either::Left(l) => Err(l), Either::Right(r) => Ok(r), } }}
fn main() { let ok: Either<String, i32> = Either::Right(21); let mapped = ok.map_right(|n| n * 2); assert_eq!(mapped, Either::Right(42)); println!("{:?}", mapped); // Right(42)
let err: Either<String, i32> = Either::Left("boom".to_string()); println!("{:?}", err.into_result()); // Err("boom")
let good: Either<String, i32> = Either::Right(7); println!("{:?}", good.into_result()); // Ok(7)}Real output:
Right(42)Err("boom")Ok(7)Note how map_right changes only the second type parameter (R to R2) while leaving L alone — the same trick Result::map uses.
Exercise 3: A mappable, summable binary tree
Section titled “Exercise 3: A mappable, summable binary tree”Difficulty: Hard
Objective: Write a generic recursive enum with a structure-preserving map and a sum that requires a trait bound on the method.
Instructions:
- Define
enum Tree<T> { Leaf(T), Node(Box<Tree<T>>, Box<Tree<T>>) }. - Add
map<U, F: Fn(&T) -> U>(&self, f: &F) -> Tree<U>producing a new tree of the same shape with every leaf transformed. - Add
sum(&self) -> Tthat only compiles whenT: Add<Output = T> + Copy(use awhereclause on the method, not the enum). - Build a tree of
i32, print its sum, map it to doubled values and toStringlabels.
Solution
#[derive(Debug)]enum Tree<T> { Leaf(T), Node(Box<Tree<T>>, Box<Tree<T>>),}
impl<T> Tree<T> { // Produce a new tree of the same shape with every leaf transformed. fn map<U, F: Fn(&T) -> U>(&self, f: &F) -> Tree<U> { match self { Tree::Leaf(value) => Tree::Leaf(f(value)), Tree::Node(left, right) => { Tree::Node(Box::new(left.map(f)), Box::new(right.map(f))) } } }
// The bound lives on the method, so building a Tree<String> stays unconstrained. fn sum(&self) -> T where T: std::ops::Add<Output = T> + Copy, { match self { Tree::Leaf(value) => *value, Tree::Node(left, right) => left.sum() + right.sum(), } }}
fn main() { let tree: Tree<i32> = Tree::Node( Box::new(Tree::Leaf(1)), Box::new(Tree::Node(Box::new(Tree::Leaf(2)), Box::new(Tree::Leaf(3)))), ); println!("sum = {}", tree.sum()); // sum = 6
let doubled = tree.map(&|n| n * 2); println!("{:?}", doubled); // Node(Leaf(2), Node(Leaf(4), Leaf(6))) println!("doubled sum = {}", doubled.sum()); // doubled sum = 12
let labels: Tree<String> = tree.map(&|n| format!("leaf-{}", n)); println!("{:?}", labels); // Node(Leaf("leaf-1"), Node(Leaf("leaf-2"), Leaf("leaf-3")))}Real output:
sum = 6Node(Leaf(2), Node(Leaf(4), Leaf(6)))doubled sum = 12Node(Leaf("leaf-1"), Node(Leaf("leaf-2"), Leaf("leaf-3")))Putting T: Add<Output = T> + Copy on sum rather than on the enum keeps Tree<String> valid (strings are not summable, but we never call sum on them). Trait bounds are covered in trait-bounds.md, and the Add trait in operator-overloading.md.