Skip to content

The Value cell — nine shapes, 32 bytes

Every cell of every record in Clinker is a single Value. A run over a million rows builds a million of them, so two questions about this one type ripple through the whole engine: what shapes can a cell take, and what does one cost in memory? You met Value in passing while tracing a record end-to-end, as “a cell, one of nine shapes.” This is the deep pass: why that set of nine is closed, and why it’s pinned at exactly 32 bytes by a test. Both are deliberate engineering. The Rust tool underneath is the enum; we use just enough of it to read the real type, and The Rust Book, ch. 6 is the canonical treatment if you want enums from first principles.

  • Name each variant of the real Value enum and say which carry data and which are bare.
  • Explain why Value is a closed sum type, and what that closedness buys CXL and the planner.
  • Predict whether the compiler will let you construct a Value that isn’t one of the declared variants.
  • Trace why one boxed variant pins the whole enum at 32 bytes, and write a small enum whose size you can read off with size_of.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • enum: a type whose value is exactly one of a fixed, named, closed set of kinds.
  • variant: one of those kinds; unit (no payload, like Null) or carrying typed data (like Integer(i64)).
  • sum type: the precise name for “a choice between alternatives”: one variant or another, never several at once.
  • Value: Clinker’s own enum for a single cell of a record. This whole lesson is about it.
clinker-record ·value.rs ·Value type @19acdcb4
pub enum Value {
Null,
Bool(bool),
Integer(i64),
Float(f64),
String(FieldStr),
Date(NaiveDate),
DateTime(NaiveDateTime),
Array(Vec<Value>),
Map(Box<IndexMap<Box<str>, Value>>),
}

Read the variants as two groups. Null is a unit variant: a name with no payload. The other eight are variants that carry data: Integer(i64) holds a 64-bit integer, Bool(bool) a boolean, Array(Vec<Value>) a list of further Values, and so on. A Value is exactly one of these at a time, and that is what makes it a sum type: a cell is an integer or a string or a map, never two at once, and never a kind not on this list.

That closedness is the engineering point, not an accident of syntax. The compiler knows the complete list; there is no tenth shape, and there can never be one without editing this declaration. Clinker’s expression language, CXL, runs a formula over every record, and CXL can be typechecked before a job runs (a typo fails at plan time, not mid-file) precisely because the set of value shapes is fixed and known in advance. The closed enum is that shared, finite vocabulary; the language, the format layer, the planner, and the runtime all rely on it.

A Record is a Vec<Value>. Every cell of every row is a Value, so the size of one Value multiplies across all your data. An enum is as wide as its largest variant (plus a small tag), so a single bloated variant would tax every cell.

Look closely at the last variant:

Map(Box<IndexMap<Box<str>, Value>>),

An IndexMap is a big struct. If it were stored inline, every Value, even a plain Integer, would be as wide as a map. So the map is Boxed: the variant holds just an 8-byte pointer, and the enum’s width is set by the genuinely small variants instead. The result is pinned by a test:

clinker-record ·value.rs ·test_value_enum_size test @19acdcb4
#[test]
fn test_value_enum_size() {
assert_eq!(std::mem::size_of::<Value>(), 32);
}

Value is 32 bytes, and that test fails the build if a change makes it bigger. Size is treated as a contract, not an accident.

You now read the real Value. Here you build the same machinery yourself, scaffolded down to an unaided task. Each rung adds exactly one idea.

Worked: construct each variant and read the size

Section titled “Worked: construct each variant and read the size”

Here is a small, self-contained enum that mirrors Value’s structure: a unit variant, a couple of data-carrying variants, and one boxed variant. Run it as given. The match shows that a value is exactly one variant; the size_of line shows the boxing trick from above, on an enum small enough to reason about whole.

rust // editable

Below, Cell is missing a Bool variant, and describe is missing the arm that handles it. Two arms are written for you. Add the Bool(bool) variant to the enum, then add the one match arm that names it; the compiler will refuse to build until every variant is handled.

rust // editable
💡 Hint 1
A data-carrying variant is Name(Type): declare Bool(bool) alongside Integer(i64). Its match arm binds the payload but can ignore it: Cell::Bool(_) => "...",. Once it builds, uncomment Cell::Bool(true) in main to see it run.
Show solution
#[derive(Debug)]
enum Cell {
Null,
Integer(i64),
Bool(bool),
}
fn describe(cell: &Cell) -> &'static str {
match cell {
Cell::Null => "absent",
Cell::Integer(_) => "a number",
Cell::Bool(_) => "a boolean",
}
}

The closed set of variants and the exhaustive match work together: add a variant and the compiler forces you to handle it everywhere, so the new shape can’t be silently dropped. That is the same guarantee the real Value gives the whole engine, and it’s the subject of the next lesson.

Now with much less scaffolding. Define an enum Payload with three variants:

  • Flag(bool): a tiny variant.
  • Number(i64): a small variant.
  • Blob(Box<[u8; 4096]>): a large buffer that you must Box so it doesn’t bloat the whole enum.

Then print size_of::<Payload>(). The skeleton and the experiment are below; write the enum and confirm the size stays small because the big buffer lives behind a pointer.

rust // editable
Show solution
use std::mem::size_of;
enum Payload {
Flag(bool),
Number(i64),
Blob(Box<[u8; 4096]>), // boxed: the variant is just an 8-byte pointer
}
fn main() {
println!("size of Payload: {} bytes", size_of::<Payload>());
// Boxed, Payload is small (a tag + a pointer-sized field, 16 bytes on a 64-bit target).
// Unboxed — Blob([u8; 4096]) — it would be just over 4 KB: every Flag and Number
// would carry the cost of the largest variant.
}

This is exactly the decision the engine made on Map(Box<IndexMap<…>>): box the one genuinely large variant so every cell, including a bare Null or a plain Integer, stays small. You just reproduced the reasoning behind the 32-byte contract.

Back to the engine’s own enum. The boxing trick you just modeled is why this runs small:

rust // editable

// quick check

Why is the Map variant wrapped in a Box?

You understand the atom: a closed sum type of nine variants, pinned at 32 bytes by a test. Next: how the engine takes one apart safely, and why the compiler forces a match to handle all nine cases.

Go deeper on the Rust (optional; the same enum machinery from first principles, in the official Rust docs):

Glossary terms used: enum, variant, sum type, Value.