Skip to content

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 payloadn, 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:

enum HouseLocation {
    // ...
    PoBox(u32),
}
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

use HouseLocation::*;
let a = Number(4);        // instead of HouseLocation::Number(4)

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 subtypesWeekday 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

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