Taps as a Library: When Syntax Becomes Just Code
What Are Taps?
Taps are Koru’s mechanism for observing event transitions. Think aspect-oriented programming, but compile-time and zero-cost:
// When 'compute' fires, also call 'logger'
~tap(compute -> *)
| result |> logger()
// Now this flow is automatically observed
~compute(x: 42)
| result r |> display(r.value) The tap inserts logger() into every transition from compute. No runtime dispatch, no virtual calls. The observer is literally woven into the code at compile time.
The Old Way: Special Syntax
Originally, taps had their own parser syntax:
// Old syntax - special parser handling
~compute -> *
| result |> logger() The parser saw ~identifier -> and activated special tap-parsing logic. This worked, but:
- 358 lines of dedicated parser code
- Special case in the compiler pipeline
- Locked knowledge - only the compiler knew how to create taps
The syntax looked like a language primitive. It wasn’t.
The New Way: Library Feature
Now taps are a library:
~import "$std/taps"
// New syntax - library feature
~tap(compute -> *)
| result |> logger() The difference is subtle but profound. ~tap(...) is a regular event invocation. The tap event is defined in koru_std/taps.kz as a [keyword|comptime|transform] event:
~[keyword|comptime|transform]pub event tap {
expr: Expression,
invocation: *const Invocation,
item: *const Item,
program: *const Program,
allocator: std.mem.Allocator
}
| transformed { program: *const Program } When the compiler encounters ~tap(...), it invokes this event’s transform. The transform:
- Parses the
source -> destinationpattern from the Expression - Walks the program AST finding matching flows
- Wraps matching continuations with the tap’s action
- Returns the transformed AST
The tap declaration compiles away. Zero runtime overhead. Same semantics as before.
The Implementation
Here’s the core of the transform (simplified):
~proc tap {
// Parse "source -> dest" from Expression
const source = parseSource(expr);
const destination = parseDestination(expr);
// Get the tap's continuation (what to insert)
const tap_action = flow.continuations[0].step;
// Walk program, wrap matching transitions
for (program.items) |item| {
if (matchesSource(item, source)) {
wrapContinuation(item, tap_action);
}
}
// Return transformed program (tap declaration removed)
return .{ .transformed = .{ .program = new_program } };
} The full implementation is ~265 lines in koru_std/taps.kz. It uses the same AST manipulation utilities that ~if and ~for use.
What We Removed
From src/parser.zig:
- // Check for tap pattern: ~source -> destination
- if (std.mem.indexOf(u8, after_tilde, "->")) |_| {
- return self.parseEventTapWithAnnotations(annotations);
- } Gone. Along with parseEventTapWithAnnotations() and parseEventTap(). 358 lines deleted.
The parser no longer knows what a tap is. It just sees ~tap(...) and treats it like any other event invocation.
Why This Matters
This isn’t just refactoring. It proves something fundamental about Koru’s design:
Features that look like syntax can be libraries.
The old tap syntax ~source -> dest looked built-in. Users would reasonably assume it was a language primitive requiring compiler changes to modify.
But it was always just AST manipulation. The compiler was doing work that user code can do.
Now anyone can:
- Read how taps work in
koru_std/taps.kz - Modify tap behavior
- Create new observation patterns
- Build domain-specific AOP systems
The same transform system powers ~if, ~for, ~capture, and now ~tap. No special cases.
Migration
Migrating from old to new syntax is mechanical:
// Old
~hello -> *
| done |> observer()
// New
~import "$std/taps"
~tap(hello -> *)
| done |> observer() Add the import, wrap in ~tap(...). That’s it.
We migrated several test cases to validate:
| Test | What it validates |
|---|---|
| Basic taps | Single event observation |
| Multiple taps | Multiple observers on same event |
| Wildcards | ~tap(* -> *) with Transition metatype |
All pass with the library implementation.
Nested Invocations Work
The tap transform properly recurses into continuations:
~tap(goodbye -> *)
| tap_target |> observer()
~hello()
| tap_target |> goodbye() // goodbye is NESTED, still gets tapped!
| tap_target |> _ The observer fires after goodbye() even though it’s nested inside hello()’s continuation. This is essential for profiling - you want to tap ALL calls to an event, regardless of where they appear in the call graph.
What’s Next
One enhancement is on the roadmap:
Void metatype - Tap void events that have no branches:
~tap(void_event -> *)
| Void |> observer() // Fires when void event completes The foundation is solid. Taps work everywhere. They’re a library. The syntax is just code.
The Lesson
When we started Koru, we imagined taps as a core language feature. They seemed too magical to be “just library code.”
We were wrong.
The same metaprogramming that powers control flow (~if, ~for) powers observation (~tap). The same Expression capture that enables string interpolation enables tap patterns.
Every time we think something needs to be built into the compiler, we should ask: can this be a transform?
Usually, yes.
~import "$std/taps"
~tap(* -> *)
| Transition t |> log(event: t.source, branch: t.branch)
// Full-program logging with one import What looks like syntax is just a library.