Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Concept: Newtypes, Transparent serde, and the Domain

Kind: Concept. New crate: uuid — this chapter shows it working before you build with it.

This is the Core arc, and it is the arc every other one leans on. control-core holds the vocabulary — the ids, the domain structs, the status enums — that the store, the service, the scheduler, and the cluster all speak. Get the types right here and the rest of the course is filling in behavior around a fixed shape. Get them loose and every later arc inherits the looseness.

You already know serde, enums, and structs cold from the first two courses. So this chapter is not about how to derive Serialize. It is about two smaller decisions that look cosmetic and are not: wrapping a Uuid in a newtype, and telling serde to serialize that newtype transparently. Both are cheap. Both buy you a class of bug the compiler catches for free.

The problem: two ids that are the same shape and must never be confused

The control plane has two id-shaped things. A run is a submitted eval sweep — the top-level unit a client creates. A job is a chunk of a run that one worker executes. Both are identified by a UUID. Structurally, a run id and a job id are identical: 128 random bits.

So the lazy move is type RunId = Uuid; type JobId = Uuid; — or worse, just pass Uuid around everywhere. The problem shows up the first time you write a function that takes both:

// Uses the `uuid` crate — add it in your scratch project (below) to run this.
fn cancel_job(run: Uuid, job: Uuid) { /* ... */ }

Now every call site is a coin flip. cancel_job(job, run) compiles perfectly and is wrong at runtime, and you find out when a job in run A gets cancelled against run B's ledger. This is exactly the stringly-typed disease from Course 1, wearing a Uuid costume: the type carries no information about which kind of id it is, so the compiler cannot help you.

The fix: a newtype per id

A newtype is a one-field tuple struct that wraps another type to give it a name and an identity:

use uuid::Uuid;

pub struct RunId(pub Uuid);
pub struct JobId(pub Uuid);

RunId and JobId now wrap the same Uuid, but they are different types. And that is the whole point: the compiler will not let you pass one where the other is expected. Predict what happens here before you read the output:

Predict first A function takes a RunId. You hand it a LineId — sorry, a JobId. Compile error, or runtime surprise? And which error code — is this a trait-bound problem, or a plain type mismatch?
use uuid::Uuid;

struct RunId(Uuid);
struct JobId(Uuid);

fn cancel_run(run: RunId) { /* ... */ }

fn main() {
    let job = JobId(Uuid::new_v4());
    cancel_run(job); // wrong id kind
}
error[E0308]: mismatched types
  --> src/main.rs:10:16
   |
10 |     cancel_run(job); // wrong id kind
   |     ---------- ^^^ expected `RunId`, found `JobId`
   |     |
   |     arguments to this function are incorrect
   |
note: function defined here

E0308, at compile time. The program never runs. Compare this to type RunId = Uuid — a plain type alias is not a new type, it is a second name for the same one, so cancel_run(job) would compile and the bug would ship. The newtype is what turns "two things that happen to be UUIDs" into "two things the compiler keeps apart."

NOTE This is the same lesson as "enums are a closed set," pointed at ids instead of categories. There the type made an off-codebook string impossible to construct; here it makes an off-role id impossible to pass. Both push a runtime mistake back to compile time — the recurring theme of the whole course.

The catch: a newtype changes the wire format — unless you say otherwise

Here is the trap, and it is the reason this chapter exists rather than a one-line footnote. serde serializes a newtype struct the way it serializes any struct — and a tuple struct with one field serializes as a one-element sequence or a wrapper, not as the bare inner value. A RunId would land on the wire looking like ["…uuid…"] or {"0":"…"} depending on format, when what you want in your JSONL logs and your HTTP bodies is just the bare quoted UUID a human (or jq, or a database column) can read.

The fix is one attribute — #[serde(transparent)] — which tells serde: serialize this struct as if it were its single field. The wrapper vanishes on the wire; the type safety stays in the code. Here is the whole toy, runnable:

// Scratch deps: serde = { version = "1", features = ["derive"] },
//               serde_json = "1", uuid = { version = "1", features = ["v4", "serde"] }
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
struct OrderId(Uuid);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
struct LineId(Uuid);

impl OrderId {
    fn new() -> Self { OrderId(Uuid::new_v4()) }
}
impl LineId {
    fn new() -> Self { LineId(Uuid::new_v4()) }
}

fn main() {
    let oid = OrderId::new();

    // #[serde(transparent)]: the wire form is the bare uuid, no wrapper.
    let json = serde_json::to_string(&oid).unwrap();
    println!("OrderId on the wire: {json}");

    let back: OrderId = serde_json::from_str(&json).unwrap();
    println!("round-tripped equal: {}", back == oid);

    let lid = LineId::new();
    println!("OrderId and LineId are different types, both wrapping a Uuid");
    println!("  order = {oid:?}");
    println!("  line  = {lid:?}");
}
OrderId on the wire: "5aad6fdf-d682-4161-ac42-9d4e6f2ff948"
round-tripped equal: true
OrderId and LineId are different types, both wrapping a Uuid
  order = OrderId(5aad6fdf-d682-4161-ac42-9d4e6f2ff948)
  line  = LineId(20ec3bd8-3883-42ae-903b-342315d51967)

(The UUIDs are random, so your run prints different ones.) Read the first line: "5aad6fdf-…" — a bare quoted string, not ["5aad6fdf-…"]. That is transparent doing its job. Drop the attribute and the same struct serializes with a wrapper, which would silently change every id column in every log file. This is why a round-trip test that pins the wire form — not just "encode then decode is equal" — is worth writing: it freezes the byte-level contract so a future refactor can't quietly reshape it.

Display, so an id can become a string when you need one

A newtype hides the inner Uuid, which means println!("{run_id}") won't work until you say how it prints — the wrapper doesn't inherit Display. You give it one line:

use std::fmt;
use uuid::Uuid;

struct RunId(Uuid);

impl fmt::Display for RunId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0) // delegate straight to the inner uuid
    }
}

Now run_id.to_string() equals the inner UUID's string, so log lines, URL path segments (/runs/{id}), and error messages all read as the bare id. transparent governs the serde wire form; Display governs the {}/to_string() form. They are two different codecs and you want both to say the same bare-UUID thing — which is why the build has a test for each.

The rest of the vocabulary: status enums and a total

The domain is mostly plain structs you could write in your sleep by now — Vignette, EvalJob, ResponseRecord, Job, Run. Two pieces are worth calling out because the build tests target them.

Status enums serialize snake_case. RunStatus is Queued | Running | Done | Failed; JobStatus is Pending | Assigned | Done | Failed. On the wire they must be lowercase ("queued", "assigned") because that is what a database column and a JSON API want — so both carry #[serde(rename_all = "snake_case")]. This is the same rename_all you used for the codebook enums, just a different casing.

JobOutcome knows its own token total. A JobOutcome holds the ResponseRecords a job produced; each record carries a Usage { input_tokens, output_tokens }. The outcome offers total_usage() that folds every record's usage into one sum — the accounting the /stats endpoint will report in Part VII. Here is that shape as a toy, runnable on serde alone:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
struct Charge {
    goods: u32,
    shipping: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Line {
    sku: String,
    charge: Charge,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Order {
    lines: Vec<Line>,
}

impl Order {
    fn total(&self) -> Charge {
        self.lines.iter().fold(Charge::default(), |acc, l| Charge {
            goods: acc.goods + l.charge.goods,
            shipping: acc.shipping + l.charge.shipping,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum OrderStatus {
    Pending,
    Shipped,
    Delivered,
    Cancelled,
}

fn main() {
    let order = Order {
        lines: vec![
            Line { sku: "A".into(), charge: Charge { goods: 100, shipping: 20 } },
            Line { sku: "B".into(), charge: Charge { goods: 50, shipping: 10 } },
        ],
    };
    println!("total: {:?}", order.total());
    println!("status: {}", serde_json::to_string(&OrderStatus::Shipped).unwrap());
    println!("status: {}", serde_json::to_string(&OrderStatus::Cancelled).unwrap());
}
total: Charge { goods: 150, shipping: 30 }
status: "shipped"
status: "cancelled"

The fold sums each field independently — 100 + 50 goods, 20 + 10 shipping — which is exactly how total_usage() sums input and output tokens across records. And OrderStatus::Shipped lands on the wire as "shipped", the snake_case convention the real status enums use.

One-for-one: the toy ↔ the build

Everything above maps straight onto control-core:

Toy (this chapter)Build (control-core)
OrderId(Uuid) transparent newtypeRunId(Uuid)
LineId(Uuid) transparent newtypeJobId(Uuid)
passing LineId where OrderId is wanted → E0308passing JobId where RunId is wanted → E0308
OrderStatus with rename_all = "snake_case"RunStatus / JobStatus
Charge { goods, shipping }Usage { input_tokens, output_tokens }
Line { sku, charge }ResponseRecord { …, usage }
Order::total() folding chargesJobOutcome::total_usage() folding usages

The build is the same three ideas — a transparent newtype per id, snake_case status enums, a fold that totals a Copy accounting struct — pointed at run/job/usage instead of order/line/charge.

Questions to lock

  1. Why does a type RunId = Uuid; alias fail to prevent the run-id/job-id mixup, while a struct RunId(Uuid); newtype prevents it? What is the mechanical difference the compiler sees?
  2. What does #[serde(transparent)] change about a newtype's wire form, and what breaks in your log files if you forget it?
  3. transparent and Display both make a RunId "look like its inner UUID." Which one governs serde_json::to_string, and which governs format!("{run_id}")? Why do you want a test for each?

Next chapter is the first build of the arc: ids.rs and domain.rs, starting from a failing test.