What an enum is¶
Level: 101 → 201 · newcomer to working knowledge
One line: An enum names a closed set of alternatives and makes that set a type — a value is exactly one of them, and a match that forgets one does not compile.
enum HouseLocation {
Number(u32), // tuple variant: one field
Name(String), // tuple variant: owns its data
GridRef { easting: u32, northing: u32 }, // struct variant: named fields
Unknown, // unit variant: carries nothing
}
let where_i_live = HouseLocation::Number(4);
Four variants, four shapes, one type. where_i_live is a HouseLocation — not a Number, which is not a type and cannot be one. A variant is a way of building the type, never a type of its own.
That is the whole declaration. Everything below is what you get for it.
The four shapes¶
| Shape | Written | Read back out with |
|---|---|---|
| unit | Unknown |
HouseLocation::Unknown |
| tuple, one field | Number(u32) |
HouseLocation::Number(n) |
| tuple, several fields | Note(String, u32) |
HouseLocation::Note(text, n) |
| struct, named fields | GridRef { easting: u32, northing: u32 } |
HouseLocation::GridRef { easting, northing } |
Mixing them in one enum is normal, not a code smell. The rule of thumb: two fields or fewer and obvious from the variant name, use a tuple; otherwise name them. GridRef(u32, u32) compiles fine and nobody will remember which number is which.
Behaviour goes in an impl block¶
Enums take impl blocks exactly the way structs do — same receivers, same associated functions. There is nothing struct-specific about them.
impl HouseLocation {
fn describe(&self) -> String {
match self {
HouseLocation::Number(n) => format!("house number {n}"),
HouseLocation::Name(name) => format!("the house called {name}"),
HouseLocation::GridRef { easting, northing } => {
format!("grid reference {easting} {northing}")
}
HouseLocation::Unknown => String::from("not known"),
}
}
}
Each arm does two jobs at once: it asks which variant is this, and it binds a name to the payload — n, name, easting. There is no separate unwrapping step, and no way to read n without having established that you are in the Number arm.
The payoff: adding a variant breaks the right builds¶
Add a fifth variant six months later:
error[E0004]: non-exhaustive patterns: `&HouseLocation::PoBox(_)` not covered
--> e0004.rs:12:15
|
12 | match self {
| ^^^^ pattern `&HouseLocation::PoBox(_)` not covered
|
note: `HouseLocation` defined here
|
7 | PoBox(u32),
| ----- not covered
The compiler hands you the list of every place that now has a hole. This is exhaustiveness, and it is most of the reason to reach for an enum instead of a string or an integer: the list of places to revisit is computed, not remembered.
A _ arm opts out of it permanently — it is a standing promise that every variant anyone ever adds belongs in that bucket:
fn postable(&self) -> bool {
match self {
HouseLocation::Unknown => false,
_ => true, // PoBox will silently be postable
}
}
Sometimes that promise is true and the _ is right. Make it deliberately, because nothing will ask you again.
use Enum::* shortens the arms, and costs more than it looks¶
Tempting inside a long match. It is also the single most expensive habit in this section, because a mistyped arm stops being an error and becomes a silent catch-all — a typo becomes a binding is that page, and it is worth reading before you adopt the import.
Fieldless enums are numbers; enums with payloads are not¶
An enum where no variant carries data can be cast to an integer, and given explicit values:
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum Day { Mon, Tue, Wed, Thu, Fri, Sat, Sun }
enum Code { Ok = 200, NotFound = 404 }
Day::Sun as u8; // 6 — position, counting from zero
Code::NotFound as i32; // 404
Day::Mon < Day::Fri; // true, once PartialOrd is derived — declaration order
Add one payload-carrying variant and the cast is gone:
error[E0605]: non-primitive cast: `HasData` as `u8`
= note: an `as` expression can be used to convert enum types to numeric types
only if the enum type is unit-only or field-less
And the reverse never works, for any enum — 1 as Day is E0605 too. An integer is not a Day, because most integers are not days. Going that direction is a fallible conversion, which is what TryFrom is for.
If you are coming from another language¶
Python. enum.Enum is the closest thing, and it stops at the fieldless case: members are singletons with a fixed .value, so there is no per-member payload. The Rust equivalent of Number(4) is a Union of dataclasses, and the equivalent of the exhaustiveness check is running mypy in strict mode — the interpreter itself will never object to a match with a missing case. What transfers is the habit: if you already reach for Enum instead of a string constant, you already have the instinct. What is new is that Rust's version carries data and that missing a case is a build failure rather than an else: pass.
ABAP. Before 7.51 you wrote a constants structure and a CASE, and the compiler checked nothing:
CONSTANTS: BEGIN OF co_status,
blank TYPE c LENGTH 1 VALUE ' ',
marked TYPE c LENGTH 1 VALUE 'M',
cast TYPE c LENGTH 1 VALUE 'C',
END OF co_status.
Any c value fits that variable, WHEN OTHERS is optional, and adding a fourth status is a grep. 7.51's TYPES: BEGIN OF ENUM t_status ... END OF ENUM t_status. fixes the first half — the variable now holds only declared values — but not the second: a CASE on an enumerated type still compiles with a missing WHEN. That second half is what Rust adds, and it is the half that finds the code you forgot.
C. A C enum is an int in a hat: any integer fits in one, switch needs no default, and there is nowhere to put a payload — for that you hand-build a struct holding a tag and a union, and remember to check the tag on every read. Rust's enum is that struct, with the check compulsory. What a union is builds both side by side.
Ada. The closest ancestor, and the one that will mislead you in a specific way. Ada's enumerations are richer than C's in exactly the directions Rust's are not:
type Day is (Mon, Tue, Wed, Thu, Fri, Sat, Sun);
subtype Weekday is Day range Mon .. Fri;
type Colour is (White, Red, Yellow, Green, Blue, Brown, Black);
type Light is (Red, Amber, Green); -- Red and Green are overloaded
Three of those have no Rust spelling. There are no range subtypes — Weekday would be a separate enum, or a runtime check. Literals are never overloaded: Red cannot belong to two enums and be sorted out by context, which is precisely why Rust makes you write Colour::Red and why the glob import that removes the qualifier is a trap. And Ada's 'Succ, 'Pred, 'Pos and 'Val have no built-in equivalent; you derive PartialOrd for comparison and write the rest.
What Rust has that Ada's enumerations do not is the payload. Ada puts that in a variant record (case Kind is when ...) — a separate feature, with a discriminant you can leave inconsistent. Rust folds the two into one construct, which is why a Rust enum feels like both at once.
Practice¶
A closed set, and what closing it buys. Define a three-variant Status, write a describe returning a &'static str for each, and predict size_of::<Status>().
Then the exercise that shows what the type is for: add a fourth variant and describe exactly what the compiler says and where. Finish by saying what a _ => arm would have done to that message, and when adding one is nevertheless right.
Solution
what_an_enum_is_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: a closed set, and the match that will not compile without you.
//!
//! rustc --edition 2024 what_an_enum_is_kata.rs -o /tmp/wei && /tmp/wei
use std::mem::size_of;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Status { Queued, Running, Done }
fn describe(s: Status) -> &'static str {
match s {
Status::Queued => "waiting for a worker",
Status::Running => "in progress",
Status::Done => "finished",
}
}
fn main() {
println!("A VALUE IS EXACTLY ONE OF THEM");
for s in [Status::Queued, Status::Running, Status::Done] {
println!(" {s:?} -> {}", describe(s));
}
println!();
println!("WHAT IT COSTS");
println!(" size_of::<Status>() {} byte", size_of::<Status>());
println!(" Three alternatives fit in one byte, because the value only has");
println!(" to record WHICH of the three it is. A struct with three bools");
println!(" would be three bytes and would also permit five states that");
println!(" mean nothing.");
println!();
println!("THE PART THAT PAYS FOR ITSELF");
println!(" Add a fourth variant -- `Status::Failed` -- and `describe` stops");
println!(" compiling with E0004: \"non-exhaustive patterns: `Status::Failed`");
println!(" not covered\". The compiler found every place in the program");
println!(" that has to think about the new case, and named them.");
println!(" That is the whole argument for a closed set: the set is written");
println!(" down in one place, and changing it is a compile error");
println!(" everywhere it matters rather than a silent fallthrough.");
println!();
println!("AND THE WAY TO GIVE THAT UP BY ACCIDENT");
println!(" A `_ => ...` arm makes the match exhaustive forever. It is the");
println!(" right thing when you genuinely mean \"anything else\", and it is");
println!(" the wrong thing on a set you own -- because the next variant");
println!(" will quietly take that arm instead of being reported.");
println!();
println!("COMPARING VALUES");
println!(" Status::Queued == Status::Queued {}", Status::Queued == Status::Queued);
println!(" Status::Queued == Status::Done {}", Status::Queued == Status::Done);
println!(" PartialEq is derived, not free: without the derive, `==` on two");
println!(" Status values does not compile. Rust asks you to say which");
println!(" behaviours a type has rather than assuming them.");
assert_eq!(size_of::<Status>(), 1);
assert_eq!(describe(Status::Done), "finished");
}
Verified output of what_an_enum_is_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
A VALUE IS EXACTLY ONE OF THEM
Queued -> waiting for a worker
Running -> in progress
Done -> finished
WHAT IT COSTS
size_of::<Status>() 1 byte
Three alternatives fit in one byte, because the value only has
to record WHICH of the three it is. A struct with three bools
would be three bytes and would also permit five states that
mean nothing.
THE PART THAT PAYS FOR ITSELF
Add a fourth variant -- `Status::Failed` -- and `describe` stops
compiling with E0004: "non-exhaustive patterns: `Status::Failed`
not covered". The compiler found every place in the program
that has to think about the new case, and named them.
That is the whole argument for a closed set: the set is written
down in one place, and changing it is a compile error
everywhere it matters rather than a silent fallthrough.
AND THE WAY TO GIVE THAT UP BY ACCIDENT
A `_ => ...` arm makes the match exhaustive forever. It is the
right thing when you genuinely mean "anything else", and it is
the wrong thing on a set you own -- because the next variant
will quietly take that arm instead of being reported.
COMPARING VALUES
Status::Queued == Status::Queued true
Status::Queued == Status::Done false
PartialEq is derived, not free: without the derive, `==` on two
Status values does not compile. Rust asks you to say which
behaviours a type has rather than assuming them.
The verified output¶
examples/what_an_enum_is.rs compiled and run:
Verified output of what_an_enum_is.rs — regenerated by tools/run_examples.py, never hand-typed.
house number 4 postable: true
the house called Dunroamin postable: true
grid reference 51820 17450 postable: true
not known postable: false
Number(4) once imported: house number 4
both are HouseLocation: 2
See also¶
SomeandNone—Optionis an ordinary enum with two variants and no special powers- Variants that carry data — what the payload costs, measured
if let— amatchwith one arm, and the exhaustiveness you trade for it- One arm, many values —
A | Band ranges, for when several variants share an answer - Six kinds of zero — writing your own enum when
Optionruns out of shapes
Po polsku¶
Deklaracja jest krótka, a pierwsze nieporozumienie bierze się z tego, że po polsku wariant brzmi jak osobny byt. Wariant nie jest typem. HouseLocation::Number to sposób na zbudowanie wartości typu HouseLocation, a nie typ, którym można oznaczyć parametr funkcji — czytelnik przychodzący z Javy ma tu odruch do odwrócenia, bo tam stała wyliczenia jest obiektem i potrafi mieć własne ciało. W Ruscie rozdzielenia dokonuje dopiero match, a warianty mają cztery kształty, dla których warto mieć polskie nazwy z rozdziału o strukturach: pusty (Unknown), krotkowy z jednym polem (Number(u32)), krotkowy z kilkoma i taki z nazwanymi polami (GridRef { easting, northing }). Mieszanie ich w jednym wyliczeniu jest normalne, a reguła kciuka jest prosta — dwa pola albo mniej i oczywiste z nazwy wariantu zostają krotką, resztę nazwij, bo GridRef(u32, u32) skompiluje się bez szemrania i nikt po miesiącu nie będzie pamiętał, która liczba jest która.
Ramię matcha robi dwie rzeczy naraz i to jest sedno całej strony: pyta który to wariant i od razu wiąże nazwę z ładunkiem (n, name, easting). Nie ma osobnego kroku wyciągania danych i nie da się przeczytać n bez wcześniejszego ustalenia, że jesteśmy w wariancie Number — dokładnie ta niemożliwość jest tym, czego pilnuje się ręcznie w C-owej parze „znacznik plus unia”. Zapłata przychodzi pół roku później, przy dodaniu piątego wariantu: E0004 wypisuje każde miejsce, w którym powstała dziura, więc lista rzeczy do przemyślenia jest policzona, a nie zapamiętana. Wyjątkiem jest _, który tę listę wyłącza raz na zawsze — to obietnica, że każdy przyszły wariant należy do tego kubełka, i czasem jest prawdziwa; podejmij ją świadomie, bo kompilator już nigdy o nią nie zapyta. Zachowanie wyliczenia mieszka w bloku impl, tak samo jak przy strukturze — nic tu nie jest „strukturalne”.
Ostatni odruch do porzucenia jest znów z C i z Pascala: że wyliczenie jest liczbą. Konwersja Day::Sun as u8 działa tylko wtedy, gdy żaden wariant nie niesie danych (numeracja idzie od zera w kolejności deklaracji, chyba że wpiszesz Ok = 200); dorzuć jeden wariant z ładunkiem i ta sama linia to E0605. W drugą stronę nie działa nigdy i dla żadnego wyliczenia — 1 as Day to też E0605, bo większość liczb nie jest dniami. Konwersja z liczby jest zawodna z natury i dlatego robi się ją przez TryFrom, a nie rzutowaniem. Kolejność deklaracji ma jeszcze jedno zastosowanie: wyprowadzone PartialOrd porównuje warianty właśnie w tej kolejności, więc Day::Mon < Day::Fri jest prawdą dopóty, dopóki ktoś nie przestawi linii w deklaracji.
Szukaj po polsku: typ wyliczeniowy · wariant wyliczenia · dopasowanie wyczerpujące · rust enum as u8 cast · rust E0004 non-exhaustive patterns · rust TryFrom integer to enum