Optional Branches: Handling What You Care About

· 10 min read

The Event Pump Problem

Every GUI application has one. A loop that reads events from the system and dispatches them:

~event pump {}
| keyboard { key: u32, pressed: bool }
| mouse { x: i32, y: i32, button: u8 }
| scroll { delta: i32 }
| quit {}
| resize { width: u32, height: u32 }
| focus_gained {}
| focus_lost {}
// ... 20 more event types

Your game needs keyboard and mouse. Maybe quit. You don’t care about scroll, resize, or focus events — yet.

In most languages you write a wildcard:

match event {
    keyboard(k) => handle_key(k),
    mouse(m) => handle_mouse(m),
    _ => {}  // silent swallow of everything else
}

That _ is a black hole. It handles quit. It handles resize. It handles an event type the OS added last year that you don’t know about. You find out the hard way.


Optional Branches

Koru lets event authors declare which branches are optional:

~event pump {}
| keyboard { key: u32, pressed: bool }    // required? No — it's a pump
| ?mouse { x: i32, y: i32, button: u8 }  // optional
| ?scroll { delta: i32 }                  // optional
| ?quit {}                                // optional
| ?resize { width: u32, height: u32 }     // optional

The ? prefix says: callers may ignore this branch. No catch-all required if you handle none. But if you handle some, you need to acknowledge the rest:

~pump()
| keyboard k |> handle_key(k.key, k.pressed)
| ?mouse m   |> handle_mouse(m.x, m.y)
|?           |> _  // acknowledge the rest — quit, scroll, resize — safely ignored

|? is the catch-all for optional branches. It’s not a wildcard. It only catches optional branches, not required ones. Add a new required branch to pump and every call site breaks until handled. Add a new optional branch and existing handlers continue to work.


The Audit Binding

If you want to know which optional branch fired without committing to handling each one:

~pump()
| keyboard k |> handle_key(k.key, k.pressed)
|? Audit e   |> log(e.branch)  // logs "mouse", "scroll", "quit", etc.

Audit is a metatype that tells you which branch fired. Useful for debugging, telemetry, or gradual adoption — you can see what you’re ignoring before you decide to handle it.


Coverage Rules

Three scenarios:

// 1. Handle none — OK, all optional branches silently ignored
~pump()
| keyboard k |> handle_key(k)

// 2. Handle some — must acknowledge the rest with |?
~pump()
| keyboard k |> handle_key(k)
| ?mouse m   |> handle_mouse(m)
|?           |> _  // required if you handle any optional branch

// 3. Handle all explicitly — |? not needed
~pump()
| keyboard k |> handle_key(k)
| ?mouse m   |> handle_mouse(m)
| ?scroll s  |> handle_scroll(s)
| ?quit      |> app.quit()
| ?resize r  |> handle_resize(r)

The rule: if you handle some optional branches explicitly, you must either handle all of them or have a |? catch-all.


API Evolution

Optional branches also solve the library evolution problem cleanly. A library author can add new optional branches without breaking existing callers:

// v1.0
~pub event http_result { status: u16 }
| success { json: []const u8 }
| client_error {}
| server_error {}

// v1.1 — adds retry, non-breaking
~pub event http_result { status: u16 }
| success { json: []const u8 }
| ?retry { after_seconds: u32 }  // new, optional
| client_error {}
| server_error {}

Old callers compile unchanged. New callers can opt in:

~http_result(status: 200)
| success json   |> handle(json)
| client_error   |> show_error()
| server_error   |> log_failure()
|? retry r       |> schedule_retry(r.after_seconds)  // adopted when ready
|?               |> _

The key distinction from Rust’s #[non_exhaustive]: optional branches are a semantic declaration by the API author. They’re saying “this case is optional” — not “I might add cases later, please ignore unknown ones.” If a case becomes required, it gets promoted to | and callers must handle it. The type system enforces intent in both directions.


The Right Tool

Optional branches are for things that are genuinely optional semantically:

  • Event pump events (keyboard required, mouse optional, scroll optional)
  • Diagnostic branches (?warning, ?debug) that don’t affect correctness
  • Extended protocol states that not all callers need to handle

They’re not for hiding breaking changes. If a new case matters to correctness, it should be required. The compiler will find every call site. That’s the point.

~event parse_result {}
| ok { value: i64 }           // required
| invalid { reason: []const u8 }  // required
| ?overflow {}                // optional — caller may ignore if they don't care about precision

The author is saying: overflow is a real case, but handling it is optional. Required cases surface immediately when added. Optional cases give callers the choice.