Catch-All Implemented: Optional Branches Ship
Catch-All Implemented: Optional Branches Ship
The |? catch-all syntax for optional branches is now implemented and working. You can:
- Mark branches as optional in event declarations with
?prefix - Ignore optional branches completely (true silent no-op)
- Observe unhandled optional branches with
|?catch-all - Optionally bind meta-events (Transition, Profile, Audit) for observability
Three weeks ago we wrote about optional branches as a design philosophy—a way to preserve exhaustiveness while enabling API evolution. This weekend, we built it.
The Syntax
Optional branches are marked with ? in the event declaration:
~event process { value: u32 }
| success { result: u32 } // Required
| ?warning { msg: []const u8 } // Optional
| ?debug { details: []const u8 } // Optional You can handle just the required branches:
~process(value: 10)
| success s |> handle(s)
// Optional branches silently ignored Or add a catch-all for unhandled optional branches:
~process(value: 20)
| success s |> handle(s)
|? |> log("Unhandled optional branch fired") Or observe with meta-events for full visibility:
~process(value: 30)
| success s |> handle(s)
|? Transition t |> log(source: t.source, branch: t.branch) Or mix explicit handling with catch-all:
~process(value: 40)
| success s |> handle(s)
| warning w |> handle_warning(w) // Explicit
|? |> log("Other optional branch") // Catches debug The syntax is working. The semantics are clean. The implementation is complete.
What “Truly Optional” Means
Here’s the killer feature. Optional branches can be completely ignored:
~event process { value: u32 }
| success { result: u32 }
| ?warning { msg: []const u8 }
| ?debug { details: []const u8 }
// Proc can return ANY branch
~proc process {
if (value > 100) return .{ .warning = .{ .msg = "Large" } };
if (value % 2 == 1) return .{ .debug = .{ .details = "Odd" } };
return .{ .success = .{ .result = value * 2 } };
}
// Flow ONLY handles required branch
~process(value: 10)
| success s |> _
// NO |? needed
// NO | warning w |> needed
// NO | debug d |> needed This compiles. This runs. This works.
If the proc returns warning or debug, execution just continues. Silent no-op. No runtime panic. No compiler error. No wildcard swallowing meaning.
This is what makes them truly optional.
The Implementation Journey
Foundation: When-Clauses (Oct 31-Nov 1)
Before we could implement catch-all, we needed conditional branching. We built:
- Flow checker for control flow validation
- Branch grouping infrastructure in the emitter
- Modified all three emission paths (normal switch, return switch, top-level flow)
- Tests 055, 055b, 055c validating when-clause exhaustiveness
When-clauses let you write:
~check(x: 10, y: 5)
| high h when h.x > 10 |> handle_very_high()
| high h when h.x > 5 |> handle_high()
| high h |> handle_medium() // else case
| low l |> handle_low() The compiler ensures exactly one continuation per branch has no when clause (the else case). This infrastructure became the foundation for catch-all.
Parser: Catch-All Syntax (Nov 1)
Extended the AST with two new fields:
is_catchall: bool- marks|?continuationscatchall_metatype: ?[]const u8- stores “Transition”, “Profile”, or “Audit”
Implemented |? parsing in parser.zig to accept:
- Simple discard:
|? |> _ - With binding:
|? Transition t |> log(t) - With any metatype:
|? Profile p |>or|? Audit a |>
Created tests 060 (parser test), 060b (validation test), 065 (silent no-op test).
Code Generation: The Bug (Nov 2)
Started implementing catch-all emission in emitter_helpers.zig. Test 066 failed immediately:
error: switch must handle all possibilities The generated switch was missing the optional branch cases. Why?
The bug: AST serializer wasn’t serializing is_catchall and catchall_metatype fields!
The parser was setting these fields correctly. But when the AST was serialized to backend.zig, those fields disappeared. The backend saw branch = "?" but is_catchall = false.
The fix: 16 lines in ast_serializer.zig to serialize both fields.
The implementation:
Added findEventByName() helper to look up event declarations by canonical name. The emitter now:
- Groups continuations by branch name
- Detects if there’s a catch-all continuation (
is_catchall = true) - Tracks which branches are explicitly handled
- Looks up the event definition to find optional branches
- Emits switch cases routing each unhandled optional branch to the catch-all handler
All at compile time. Zero runtime overhead. No wildcards.
8 commits. 24 hours. Working.
How It Works
When you write:
~process(value: 42)
| success s |> handle(s)
|? |> log("Unhandled optional") The compiler:
- Parser: Recognizes
|?as catch-all, setsis_catchall = true - Flow Checker: Validates required branches are handled, skips optional branches in coverage check
- Emitter:
- Generates switch with explicit
successcase - Looks up
processevent definition - Finds optional branches (
warning,info) - Generates cases for unhandled optional branches:
.warning => |_| { /* catch-all pipeline */ }, .info => |_| { /* catch-all pipeline */ },
- Generates switch with explicit
The generated code is clean. The semantics are predictable. The type system is honest.
What This Enables
Library authors can add optional branches without breaking downstream code:
// Library v1.0
~event http_request {}
| success { json: []const u8 }
| error { code: u32 }
// Library v1.1 - non-breaking!
~event http_request {}
| success { json: []const u8 }
| error { code: u32 }
| ?redirect { location: []const u8 } // New optional All existing code continues to compile and run. Services that care about redirects can opt in. Services that don’t can ignore it.
Service developers can adopt new behaviors incrementally:
// Phase 1: Just handle core cases
~http_request(url: endpoint)
| success json |> parse(json)
| error e |> show_error(e)
// Phase 2: Add redirect handling when ready
~http_request(url: endpoint)
| success json |> parse(json)
| error e |> show_error(e)
| redirect r |> follow(r.location) // Now we care
// Phase 3: Catch-all for observability
~http_request(url: endpoint)
| success json |> parse(json)
| error e |> show_error(e)
| redirect r |> follow(r.location)
|? Transition t |> metrics.track(t) API evolution becomes a conversation, not a mandate:
When the library author decides redirects are critical enough to require:
| redirect { location: []const u8 } // Remove ? → Compile errors everywhere. Callers adapt intentionally.
No accidental ignorance of critical behavior. No wildcards eating future meaning. The type author sets the rules.
The Tests
Test 055: When-clause exhaustiveness validation Test 060: Parser accepts all catch-all syntax variations Test 065: Optional branches can be completely ignored Test 066: Catch-all routes unhandled optional branches correctly
All passing. The implementation is solid.
You can see the full tests in the Koru repository.
Implementation Stats
Time: 24 hours (Oct 31 - Nov 2) Commits: 8 Lines changed: ~24,000 (including when-clause foundation) Tests added: 6 Tests passing: 62.4% (153/245) - maintained through all refactoring
This wasn’t a sprint. It was methodical:
- Build foundation (when-clauses)
- Extend parser (catch-all syntax)
- Fix bugs as they surface (serialization)
- Test rigorously (multiple test cases)
- Clean up (abstract impl refactor)
The result: A feature that works exactly as designed.
The Difference from Rust
Rust solves API evolution with #[non_exhaustive] and wildcard patterns:
match result {
Success(json) => handle(json),
ClientError => show_error(),
_ => fallback(), // Black hole for future cases
} Koru solves it by making optionality explicit in the type:
~event result {}
| success { json: []const u8 } // Required
| client_error {} // Required
| ?redirect { location: []const u8 } // Optional
~result()
| success json |> handle(json)
| client_error |> show_error()
// redirect silently ignored OR
|? |> log("Unhandled optional fired") The type author decides what’s required and what’s optional. The compiler enforces required branches. Optional branches can be ignored until you’re ready.
No wildcards. No hidden surprises. The type is the truth.
What’s Next
With optional branches and catch-all working, we can now:
- Build libraries that evolve gracefully
- Add observability without forcing handlers
- Let services adopt features at their own pace
- Promote optional branches to required when they become critical
The foundation is solid. The syntax is clean. The semantics are honest.
Closing
Three weeks ago, we wrote about how optional branches could solve the exhaustiveness vs evolution tension.
Today, it compiles. Tomorrow, we build on it.
The vision becomes reality, one test at a time.
This feature is part of the November sprint, which also shipped when-clause branching, flow validation, and meta-event infrastructure.
Read the philosophical foundation: Optional Branches and Exhaustive Futures
This is an AI-first project. Every feature emerges from human-AI collaboration. The vision is human, the implementation is partnership, the tests keep us honest.