What Are Transition Metatypes?
What Are Transition Metatypes?
Koru’s event taps let you observe transitions between events at compile time. If you’ve read Taps as a Library or Full Program Profiling in One Line, you’ve seen them in action. But those posts skipped over a question that turns out to be interesting.
When you tap a specific event, you know its branches:
~tap(auth.check -> *)
| success u |> log(u.username)
| failure f |> log(f.reason) The compiler knows that auth.check produces success and failure. It knows their payload types. The branch bindings u and f are typed. Everything works the way regular flow matching works.
Now write a universal tap:
~tap(* -> *)
| ??? |> What goes in the branch position? You don’t know the source event. It could be auth.check or file.read or payment:complete. Each has different branches with different payloads. There is no single branch name that makes sense here.
This is the problem. Koru is statically typed and resolved at compile time. But universal observation requires handling shapes that are unknown at parse time.
The Answer: Type the Transition, Not the Payload
Instead of binding the event’s branch payload — which varies per event — you bind a description of the transition itself:
~tap(* -> *)
| Profile p |> log(source: p.source, dest: p.destination, branch: p.branch) Profile isn’t a branch of any event. It’s a synthetic struct that the compiler generates, describing what just happened: which event fired, which branch was taken, where control is flowing next.
The compiler walks your program’s AST, finds every transition that matches the tap pattern, and inlines a struct instantiation at each one. At runtime, there’s no dispatch, no registry lookup. The observer code is spliced directly into the transition points.
This is what a transition metatype is: a compiler-synthesized type that makes the program’s own flow graph into typed, observable data.
Three Tiers, Three Cost Models
We didn’t make one metatype. We made three, because “observe everything” and “observe one critical event” have different cost profiles and different needs.
Transition — Enums, 12 Bytes
~tap(* -> *)
| Transition t |> metrics.record(t.source, t.branch, t.destination) pub const Transition = struct {
source: EventEnum, // u32
destination: ?EventEnum, // u32
branch: BranchEnum, // u32
}; The compiler generates EventEnum and BranchEnum from the set of all events in your program. Each transition is three integers. No strings, no timestamps, no allocation.
The use case is production telemetry — span-level observation on hot paths. Increment a counter indexed by enum value. Build a transition frequency matrix. Feed a ring buffer for post-mortem analysis. Resolve to human-readable names only when exporting to a dashboard, not per-transition.
This is the same pattern used by Linux tracepoints and ETW: numeric IDs on the hot path, string resolution offline.
Profile — Strings and Timing, 32+ Bytes
~tap(* -> *)
| Profile p |> profiler.record(
source: p.source,
dest: p.destination,
duration: p.timestamp_ns
) pub const Profile = struct {
source: []const u8, // interned string
destination: ?[]const u8, // interned string
branch: []const u8, // interned string
timestamp_ns: i128, // nanoseconds since epoch
}; Human-readable event names and nanosecond timestamps. The strings are compile-time constants baked into the binary — no allocation at the observation site. The timestamp is a syscall.
The use case is performance profiling and timeline reconstruction. This is what powers full-program profiling. Point a universal tap at Chrome Trace format and you get a flamegraph of your entire program’s event flow.
Audit — Full Forensics, Variable Size
~tap(payment:complete -> *)
| Audit a |> compliance.record(
source: a.source,
branch: a.branch,
payload: a.payload,
time: a.timestamp_ns
) pub const Audit = struct {
source: []const u8,
destination: ?[]const u8,
branch: []const u8,
timestamp_ns: i128,
payload: ?[]const u8, // serialized continuation payload
}; Everything Profile has, plus the serialized data that flowed through the transition. Not just what happened — what was in the pipe.
In production, Audit is typically targeted at critical events. The tap pattern above is payment:complete -> *, not * -> *. You serialize the payload of payment:complete because a compliance officer needs to prove that every payment completion was logged with its full data. The cost is proportional to how many payments you process, not how many transitions your program has.
But that’s only the production story.
Conditional Compilation Changes Everything
Koru supports conditional imports and conditional taps through compiler flags. A tap gated by ~[debug] is included when you compile with --debug and completely absent from release builds. Not disabled — absent. The code doesn’t exist.
This means the “typical scope” of each tier depends on the build:
// Always on, production: enum-based span telemetry
~tap(* -> *)
| Transition t |> spans.record(t)
// --profile builds only: full timing data
~[profile]tap(* -> *)
| Profile p |> profiler.trace(p)
// Always on, production: targeted audit on critical events
~tap(payment:complete -> *)
| Audit a |> compliance.log(a)
// --debug builds only: full forensics on EVERYTHING
~[debug]tap(* -> *)
| Audit a |> forensics.dump(a) That last line is the one that matters. In debug mode, you can turn on full Audit — serialized payloads, timestamps, the works — on every transition in your entire program. The cost is enormous and you don’t care, because you’re debugging. You want to see everything. When you ship, the ~[debug] tap vanishes and only your targeted production taps remain.
This is why the tiers exist as separate types rather than a single configurable struct. Each tier isn’t just a different amount of data — it’s a different deployment context:
| Tier | Production | Debug |
|---|---|---|
| Transition | * -> * — always on, near-zero cost | — |
| Profile | source -> * — targeted | * -> * — full program profiling |
| Audit | payment:complete -> * — critical events | * -> * — full program forensics |
The same syntax, the same types, the same tap mechanism. What changes is the scope and the compiler flag that controls whether it exists in the binary.
A library can ship all three as conditional imports:
~import "$std/spans" // Transition: always on
~[profile]import "$std/profiler" // Profile: opt-in
~[debug]import "$std/forensics" // Audit: debug only Three lines. Three levels of observability. Each one a single import that installs its taps, does its work, and disappears from builds that don’t need it. No configuration files. No runtime flags. No overhead from features you didn’t ask for.
Why Three Tiers?
The tiers map to three observability concerns that the industry uses separate tools for:
| Tier | Industry Equivalent | What It Captures |
|---|---|---|
| Transition | Prometheus, OpenTelemetry spans | Which events fired, which branches taken |
| Profile | Jaeger, Chrome Trace, APM | Above + human-readable names + timing |
| Audit | Audit logs, compliance systems | Above + the actual data in the pipe |
Prometheus for metrics. Jaeger for tracing. Audit logs for compliance. Koru unifies them under one syntax. The tier keyword is the only thing that changes.
The compiler only generates infrastructure for tiers actually used. If your program only uses Transition, no Profile or Audit structs are emitted. No enums are generated unless Transition is referenced. You pay for what you use, at the granularity of the tier.
What the Compiler Does
When you write | Profile p |> in a tap, the compiler:
- Scans the AST for all events matching the tap pattern
- Collects every event name and branch name referenced
- Generates enums (if
Transitionis used) from the collected names - Generates structs for each tier that’s actually referenced
- Inlines a struct instantiation at each matching transition point
The generated code lives in a taps namespace. For Transition, the compiler also emits eventToString() and branchToString() helper functions for offline resolution.
All of this happens at compile time. At runtime, the metatype is a struct literal at the transition site. The tap handler receives it as a regular function argument.
The Design Insight
Transition metatypes exist because Koru’s flow model creates a natural boundary that most languages don’t have.
In an imperative language, transitions between states are invisible. “The next line ran.” There’s nothing to observe without explicit instrumentation.
In Koru, every event invocation is a typed boundary. Control flows from one event to the next through explicit branches. These boundaries already exist in the AST — the compiler already knows every transition point, every branch, every destination.
Metatypes just give that structure a shape you can hold. They turn the program’s flow graph from something the compiler sees into something the program can see about itself.
The broader pattern: the compiler isn’t just checking types. It’s synthesizing types from the structure of your program. Phantom types synthesize state-tracking constraints. Metatypes synthesize transition-describing structs. In both cases, the compiler creates types that don’t exist in your source code, in response to patterns it observes in your program’s flow.
Types that describe not just what your data looks like, but what your program did and where it’s going.