Units of Measure, For Free

· 4 min read

F# shipped units of measure as a dedicated language feature in 2008. Frink built a whole interpreter around them. Both let you write 1.0<celsius> and have the compiler refuse to add it to a <meter>.

Koru just got the same thing — without adding a language feature.

In 1999, NASA lost the Mars Climate Orbiter. $327M of hardware entered the Martian atmosphere at the wrong altitude and burned up because Lockheed Martin’s ground software produced thruster impulses in pound-force-seconds and JPL’s navigation software interpreted them as newton-seconds. The values were numerically fine. The units were wrong, and nothing in either pipeline checked. Every academic paper on units-of-measure type systems written since then cites that crash. It’s the canonical “this is why we type-check units” story.

Koru now catches that class of bug at compile time, without a units module, without dimensional-analysis machinery — just phantom labels that happen to be allowed on primitive types now.

What it looks like

An i32<meter> distance and an i32<second> time flow into a converter event whose output is declared i32<meter/second>:

~import std/io

~event read_distance { trip_id: u8 }
| traveled i32<meter>

~read_distance = traveled 100

~event read_time { trip_id: u8 }
| elapsed i32<second>

~read_time = elapsed 4

~event compute_velocity { d: i32<meter>, t: i32<second> }
| result i32<meter/second>

~compute_velocity = result d / t

~read_distance(trip_id: 1)
| traveled m |> read_time(trip_id: 1)
    | elapsed s |> compute_velocity(d: m, t: s)
        | result v |> std/io:print.ln("{{ v:d }} m/s")
Output
25 m/s

Three things doing real work here:

  • ~read_distance = traveled 100 is a subflow: no ~proc block, just event = branch expression. The phantom checker accepts the bare 100 literal as constructing the declared i32<meter> output.
  • ~compute_velocity declares its phantom transition in the event signature: in <meter> and <second>, out <meter/second>. The body is one line of arithmetic that the checker treats as constructing the declared output.
  • The final print.ln consumes the <meter/second> value via stdlib without caring about the label — printing doesn’t constrain phantoms.

The / in <meter/second> isn’t a divide. It’s just one more character in the label string the semantic checker tracks. The actual divide is d / t in the subflow body. What makes the slash mean something is that the author declared a producer whose input phantoms are <meter> and <second> and whose output phantom is <meter/second>. Physical-units consistency lives in the event signatures — not in the compiler. ECS component sets (*Entity<transform+sprite>), HTTP state machines (<sending->receiving->done>), or any other domain tag shape rides the same pipe.

What it catches

The simplest possible shape: a literal at the call site. Annotate the unit and it works; omit the unit and the compiler refuses to compile. Same setup, same converter, the only difference is the suffix on 22.5.

With the unit:

~import std/io

~event to_fahrenheit { c: f32<celsius> }
| result f32<fahrenheit>

~to_fahrenheit = result c * 9.0 / 5.0 + 32.0

~to_fahrenheit(c: 22.5<celsius>)
| result f |> std/io:print.ln("{{ f:f }} F")
Output
72.5 F

Without it:

~event to_fahrenheit { c: f32<celsius> }
| result f32<fahrenheit>

~to_fahrenheit = result c * 9.0 / 5.0 + 32.0

~to_fahrenheit(c: 22.5)           // ERROR: bare literal, no <celsius>
| result _ |> _
Output
error[KORU030]: Phantom state mismatch: argument 'c' has no tracked phantom state, but event requires '<celsius>'. The value must be in state 'input:celsius'.
  --> phantom_semantic_check:7:0

KORU030, before the binary is built. The Mars Climate Orbiter shape in miniature: a value enters a typed event boundary without the unit the boundary requires. In a real MCO-shaped failure this fires the first time a units-mismatched value flows through any event signature — which is to say, before integration testing, before launch, before $327M of hardware enters a trajectory it can’t recover from.

Same rejection happens if you cross labeled units: passing an i32<second> where i32<meter> is required, or f32<fahrenheit> where f32<celsius> is. Different label, different rejection, same compile-time stop.

What changed

Phantom labels used to ride on pointers and user types. Now they ride on primitives too. That’s it. The semantic checker didn’t need to learn anything new about units; it was already pluggable, already opaque-string-based. The carrier just had to widen.

Units of measure were sitting on the other side of that change, waiting. f32<celsius>, u64<bytes>, f64<bps>, i32<nanoseconds> — they all compose the same way the existing phantom states do, including module qualification (physics:celsius, net:bps) to avoid collisions across libraries.

The label has no cleanup obligation, no disposal, no <!state> consumer side. Temperatures don’t get closed. The full obligation-flavored system — the part that catches resource leaks at compile time, lifts raw C pointers into semantic space, tracks identity through pipelines — is the subject of an earlier post: Phantom Types in Koru: Resource Safety Without Runtime Cost. This post is the smallest application of the same machinery.

Same checker. Same zero runtime cost. New carrier.

The unit suffix is one of three things the same carrier widening unlocked. The next post — Taint Tracking, For Free — shows what happens when you put the obligation marker ! on a phantom label that rides on []const u8. Spoiler: SQL injection, XSS, and command injection all become compile errors. Same checker as resource cleanup, same checker as the unit examples above, no new analysis pass.

Tests

Related