Taps as a Library: When Syntax Becomes Just Code

· 10 min read

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:

  1. 358 lines of dedicated parser code
  2. Special case in the compiler pipeline
  3. 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:

  1. Parses the source -> destination pattern from the Expression
  2. Walks the program AST finding matching flows
  3. Wraps matching continuations with the tap’s action
  4. 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:

TestWhat it validates
Basic tapsSingle event observation
Multiple tapsMultiple 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.