Skip to content

Enums — modeling one-of-several

The Rust question for this lesson: how do you model something that is exactly one of a few kinds — where the kinds are mutually exclusive, and each kind may carry its own data?

That pattern is everywhere. A traffic light is red, amber, or green. A keypress is a letter, a digit, or an arrow — and the letter case carries which letter. The kinds never overlap (never red and green) and never vanish (it’s always some colour). Rust has a purpose-built tool for this, the enum, and learning it well is the single biggest step from “I can read Rust syntax” to “I understand how Rust models a problem.” We’ll learn it on a tiny example with no engine in sight, then watch the exact same machinery turn up at the core of a real data engine.

Forget data engines for a minute — we’re drawing shapes. A shape is one of a few kinds, and each kind needs different information: a circle needs a radius, a rectangle needs a width and a height, a bare point needs nothing at all.

#[derive(Debug)]
enum Shape {
Point, // a kind that carries no data
Circle(f64), // carries one number: the radius
Rectangle(f64, f64), // carries two: width, then height
}

Each line inside the enum is a variant — one kind a Shape is allowed to be. Two flavours appear here:

  • Point is a unit variant: just a name, no payload.
  • Circle(f64) and Rectangle(f64, f64) are tuple variants: a name plus data tucked inside the parentheses. The f64 is the type of that data — a 64-bit floating-point number. Rust makes you state the type; that precision is deliberate.

The promise the compiler now enforces: a Shape value is exactly one of these at any moment. Never a circle that’s also a rectangle. Never a shape that is somehow none of them.

You build one by naming the variant and supplying its data:

let a = Shape::Circle(2.0);
let b = Shape::Rectangle(3.0, 4.0);
let c = Shape::Point; // unit variant: nothing to supply

Press ▶ run — then change a number, add another Shape::Point, or give Circle two arguments and read the compiler’s complaint.

rust // editable

// quick check

In Rectangle(f64, f64), what do the two f64s represent?

In a language without enums, you fake “one of several kinds” — a status integer plus a comment about what each number means; a bag of nullable fields where only some are set at a time; a class hierarchy you hope nobody subclasses wrongly. Every one of those lets an illegal state exist: status 7 that means nothing, a “circle” with a width but no radius. Rust’s enum makes “exactly one kind, with exactly its own data” a fact the type system guarantees — illegal combinations simply cannot be constructed.

That guarantee is the foundation the next two lessons build on: because a value is provably one known kind, the compiler can force you to handle every kind (match, lesson 2) and can model “maybe absent” as just another enum (Option, lesson 3).

Here is where it earns its keep. Clinker is a data engine: it pulls in rows of data and runs transformations over them. The most central type in the whole engine — the single “cell of data” everything else is assembled from — is an enum, and it is exactly the pattern you just wrote, scaled up:

clinker-record ·value.rs ·Value type @47d2e12
#[derive(Debug, Clone)]
pub enum Value {
Null, // unit variant — like Shape::Point
Bool(bool), // tuple variants — like Shape::Circle
Integer(i64),
Float(f64),
String(FieldStr),
Date(NaiveDate),
DateTime(NaiveDateTime),
Array(Vec<Value>), // ← holds more Values
Map(Box<IndexMap<Box<str>, Value>>), // ← holds more Values
}

Read it with Shape in mind and it’s familiar: Null is a unit variant (a cell that’s empty), and Integer(i64) / Bool(bool) are tuple variants carrying typed data — the same two flavours, just nine kinds instead of three. (Ignore FieldStr, Box, and IndexMap for now; they’re optimisations we unpack in later lessons.)

Two things Value does that our Shape toy didn’t:

  1. It nests. Array(Vec<Value>) is a list of Values, and Map holds Values too. An enum whose variants contain the enum itself is recursive — and that’s how nine flat kinds describe data of any depth: a list of numbers, a record of fields, a list of records of lists.
  2. The set is deliberately closed — and that’s a feature. Clinker has a little expression language, CXL, that runs a formula over every row (think of a spreadsheet formula applied down a column of a million rows). For CXL to be checked before a job runs — so a typo fails at planning time, not halfway through the file — the kinds of value must be fixed and known in advance. This enum is that fixed set, written down once and trusted by the language, the on-disk format, the planner, and the runtime alike.

The #[derive(Debug, Clone)] on top is the very shortcut you used on Shape: it auto-generates the code to print the value (Debug) and to deep-copy it (Clone).

A read-only design exercise — nothing in the repo is edited.

A calendar app must store one reminder trigger, which is exactly one of: at a specific timestamp, N minutes before the event, or never. Sketch the enum — variant names and the payload each needs. Then answer: if you were storing the “N minutes” value inside clinker, which Value variant fits, and why would Value::String be the wrong call?

💡 Hint 1

Three kinds → three variants. “Never” carries nothing (a unit variant); the other two each carry one piece of data. For the clinker half: “minutes” is a whole number you’ll later do arithmetic on.

Show solution
enum ReminderTrigger {
Never, // unit variant
At(DateTime), // a timestamp
MinutesBefore(u32), // a count of minutes
}

Inside clinker, the minutes belong in Value::Integer(i64) — it’s a whole number you’ll compare and subtract. Value::String would force every later step to re-parse the text back into a number (slower, and able to fail at runtime on bad input), throwing away the type guarantee the enum exists to give you.

  • “An enum is just named integers, like in C.” No. A Rust enum is a sum type: each variant can carry its own typed payload (Circle(f64), Integer(i64), Array(Vec<Value>)). The C kind is only the special case where no variant carries data.
  • “A value might be two variants at once, or none.” Never. It is exactly one variant at any instant — that is the guarantee the compiler gives you for free.

The verify commands run in the clinker checkout (read-only, pinned 47d2e12). test_value_enum_size is clinker’s own test pinning the in-memory size of Value — a first hint of the memory reasoning we reach in the lifetimes-and-memory phase.

You can now define a type that is one of several kinds. The obvious next question is: given such a value, how do you do something different for each kind — and have the compiler stop you if you forget one? That tool is match, and it’s lesson 2 — where we’ll put Shape (and Value) to work.