Axle v0.14.1

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 : T after the enum name picks the integer type that stores the tag (i8…i64, u8…u64; default i32). 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 integersinterop values that must equal specific numbers and never gain a match
string statesnever — 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. @derive synthesises a trait’s methods and applies to a non-generic class only; a struct is refused the same way. An enum that needs equals or hashCode writes it, or implements a trait declaring it.
  • static is refused on a variant — a variant is not storage. A class-level constant belongs to a static field on a class.
  • 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

enumvariantpayloaddiscriminantvalue-typematch