Enums
An enum names a closed set of cases. Each case is a variant, and
a value of the enum is exactly one variant at a time. It is Axle’s tool
for modelling a fixed alphabet of states — a colour, a direction, a
message kind — so the compiler can check you handled every case.
The defining property: an enum is a value type. A plain enum is a single named integer tag — never heap-allocated, copied by value, with no object identity and no RTTI header. Even a data-carrying enum stays a value type: its payload is stored inline, so passing an enum around copies its bytes, not a pointer.
Enums pair naturally with match: a match over
an enum is checked for exhaustiveness, so adding a variant turns every
unhandled site into a compile error.
The simplest enum
List the variants; reach a variant through the enum name with :::
enum Color { Red, Green, Blue }
fn main() : i32 {
let c : Color = Color::Green;
if (c == Color::Green) { println("green"); }
return 0;
} Under the hood each variant is a distinct integer. == / != compare the
tag, so equality on a plain enum is a single integer comparison. Reaching
a variant always goes through :: (Color::Red), the same static-access
operator used for stdlib utilities and module names — never ..
Backing type and discriminants
By default the tag is an i32 and the variants number from 0 upward.
Two knobs override that:
- The backing type — an optional
: Tafter the enum name picks the integer type that stores the tag (i8…i64,u8…u64; defaulti32). Use a narrow type when the enum lives in a large array and every byte counts. - Explicit discriminants — a variant may fix its integer value with
= N; the variants after it auto-increment from there.
enum Level : i8 {
Lo = 5, // 5
Mid, // 6 (auto-incremented)
Hi = 10, // 10 (explicit again)
} A variant’s integer value is reachable with an as cast to the backing
type (or any wider integer). The widening reads the tag the way the
backing type is declared, so an unsigned repr keeps its value:
enum Flags : u8 {
All = 200,
}
fn main() : i32 {
return Flags::All as i32; // 200
} enum Level : i8 {
Lo = 5,
Mid,
Hi = 10,
}
fn main() : i32 {
let v : Level = Level::Hi;
return v as i32; // 10
} This is the “C-style enum” shape: a named constant set with a chosen numeric encoding, useful when the values cross an FFI boundary or index a table.
Data-carrying variants
A variant may carry data — a payload of one or more values, written in (…) after the variant name. The enum becomes a tagged union: a value
holds the tag plus the payload of whichever variant it is, all stored
inline (a value type, no allocation):
enum Shape {
Circle(i32), // one payload field
Rect(i32, i32), // two payload fields
Empty, // no payload
} You construct a variant by calling it with its payload, and destructure it with a match that binds the payload fields:
enum Shape {
Circle(i32),
Rect(i32, i32),
Empty,
}
fn area(s : Shape) : i32 {
return match s {
Shape::Circle(r) => r * r * 3,
Shape::Rect(w, h) => w * h, // binds both fields
Shape::Empty => 0,
};
}
fn main() : i32 {
let s : Shape = Shape::Rect(4, 5);
return area(s); // 20
} The tag and payload travel together as one inline value. In memory the
enum is { tag, storage } where storage is sized for the largest
variant’s payload:
Shape::Rect(4, 5) Shape::Circle(9) Shape::Empty
┌──────┬──────┬──────┐ ┌──────┬──────┬──────┐ ┌──────┬─────────────┐
│ tag │ 4 │ 5 │ │ tag │ 9 │ ---- │ │ tag │ (unused) │
└──────┴──────┴──────┘ └──────┴──────┴──────┘ └──────┴─────────────┘
Rect w h Circle r unused Empty String payloads
A payload field may be a string. The enum is still an inline value type,
but a string payload is an owning field — the compiler frees it
exactly once when the enum value goes out of scope, even after the value
has been copied into another binding or passed by value into a function:
enum Msg {
Text(string),
Code(i32),
Empty,
}
fn classify(m : Msg) : i32 {
return match m {
Msg::Text(s) => 1, // `s` binds the payload string
Msg::Code(c) => c,
Msg::Empty => 0,
};
}
fn main() : i32 {
let a : Msg = Msg::Text("hello");
let b : Msg = Msg::Code(7);
return classify(a) + classify(b); // 1 + 7 = 8
} Owned-object payloads
A payload may also own a heap object — a class value. The enum moves the
object into its inline storage, and the value’s scope-exit runs the
object’s destructor and frees it once. Moving a local into a payload
consumes that local, so it is not dropped a second time — the same
move-tracking that governs own parameters (see the memory model).
Enums with behaviour
An enum body may declare methods and associated functions after its variants, just like a class — while staying a value type (dispatch is static; there is no vtable).
Methods — an instance operation
A method takes the enum value as its self receiver. Inside the body, match self reads the current variant, which is how a method computes
over the payload:
enum Shape {
Circle(i32),
Rect(i32, i32),
pub fn area(self) : i32 {
return match self {
Shape::Circle(r) => r * r,
Shape::Rect(w, h) => w * h,
};
}
}
fn main() : i32 {
let c : Shape = Shape::Circle(5);
return c.area(); // 25
} Because an enum is a value type, self crosses by value: a method
receives a copy, and the caller’s binding keeps its variant whatever the
method does. A mut self receiver is accepted, but there is nothing to
write through — an enum carries no named field, and assigning self itself is rejected (E0001, invalid assignment target). The idiomatic
“mutating” operation therefore returns a new value:
enum Switch {
Off,
On,
pub fn toggled(self) : Switch { // returns the flipped value
return match self {
Switch::Off => Switch::On,
Switch::On => Switch::Off,
};
}
}
fn main() : i32 {
let s : Switch = Switch::Off;
let t : Switch = s.toggled(); // s is still Off; t is On
return match t { Switch::Off => 0, Switch::On => 1 }; // 1
} Associated functions — a factory on the type
An associated function declares no self receiver and is reached
through the enum name. It typically builds and returns a variant — a named
constructor / factory:
enum Direction {
North,
East,
South,
West,
pub fn default() : Direction { // no `self` — an associated fn
return Direction::West;
}
pub fn index(self) : i32 { // a method — has `self`
return match self {
Direction::North => 0,
Direction::East => 1,
Direction::South => 2,
Direction::West => 3,
};
}
}
fn main() : i32 {
let d : Direction = Direction::default(); // reached via the enum name
return d.index(); // 3
} The rule mirrors classes: self-bearing members are methods called on
a value (d.index()), receiver-less members are associated functions called on the type (Direction::default()).
Enums and traits
An enum can implement a trait, so an enum value flows into a trait-typed slot and dispatches dynamically. Because the enum is a value type with no RTTI header of its own, flowing into a trait slot boxes it into a tagged carrier whose type tag drives the dispatch — the mechanics are handled for you; at the source level it reads exactly like a class implementing a trait:
trait Area {
fn area(self) : i32;
}
enum Shape : Area { // the `: Area` clause opts in
Circle(i32),
Rect(i32, i32),
pub fn area(self) : i32 {
return match self {
Shape::Circle(r) => r * r,
Shape::Rect(w, h) => w * h,
};
}
}
fn compute(a : Area) : i32 { // takes any Area — dynamic dispatch
return a.area();
}
fn main() : i32 {
let c : Shape = Shape::Circle(5);
return compute(c); // 25
} A trait’s default method bodies graft onto an enum implementer the
same way they do onto a class: if the enum provides the trait’s required
methods, an omitted default-bearing method is inherited, lowered with self as the enum value. See Generics and traits and the OOP recipe for the trait surface.
Exhaustive match — the payoff
The reason to model a closed set as an enum rather than a set of integer
constants: a match over an enum that names every variant needs no _ arm, and the compiler proves it exhaustive. Add a variant later, and
every match that forgot it stops compiling — the type system hands you
the checklist:
enum Color { Red, Green, Blue }
fn code(c : Color) : i32 {
return match c {
Color::Red => 0,
Color::Green => 1,
Color::Blue => 2,
// no `_` — and adding `enum Color { …, Alpha }` breaks this on purpose
};
} A variant may be named bare in a pattern — Green instead of Color::Green — and it still resolves against the scrutinee’s enum as
that variant, not as a fresh binding. The full pattern rules (payload
destructuring, guards, or-patterns) are in Pattern matching.
enum vs the alternatives
| Model a closed set as… | When |
|---|---|
enum (this page) | a fixed set of cases, especially if match should be exhaustive or a case carries data |
const integers | interop values that must equal specific numbers and never gain a match |
string states | never — a typo ("amdin") slips to runtime; an enum rejects it at compile time |
Prefer an enum over a bag of strings or loose integers for any state a
program branches on: the compiler then rejects an impossible value and a
forgotten case before the program runs.
Limitations
- An enum takes no
@derive—E0012.@derivesynthesises a trait’s methods and applies to a non-genericclassonly; astructis refused the same way. An enum that needsequalsorhashCodewrites it, or implements a trait declaring it. staticis refused on a variant — a variant is not storage. A class-level constant belongs to astaticfield on aclass.- A payload field is positional. It is bound by
match, not reached by name, so a variant’s payload carries no accessor. - The variant set is closed. There is no way to add a variant from another file or another module.
See also
- Pattern matching — how
matchdestructures a variant and enforces exhaustiveness. - Language tour → Top-level declarations — enums alongside the other top-level shapes.
- Generics and traits — trait bounds, default bodies, and dynamic dispatch that enums plug into.
- Memory model — how an owned payload’s lifetime is managed.
- Concept index — every keyword on one page.