Optional Branches and Exhaustive Futures: A Different Path for API Evolution

Exhaustive pattern matching is one of those features that feels almost magical the first time you encounter it. The compiler won’t let you forget a case. Changing a type means updating every handler. It’s the language saying:

“You sure you didn’t miss anything important?”

Rust, F#, and other ML-style languages proudly enforce this safety.

Until they can’t.

Real software lives in ecosystems — libraries evolve, dependencies stack — and the moment a library adds a new enum case, strict exhaustiveness becomes a liability: everything downstream breaks. Rust and F# relax guarantees through wildcard or discard patterns, and Rust even offers #[non_exhaustive] to force downstream code to be future-proof by not being fully exhaustive.

This is incredibly practical for a large ecosystem. But it introduces a quiet contradiction:

If a behavior is meaningful enough to add to the type, shouldn’t it be meaningful enough for callers to acknowledge?

That tension has always bugged us.

We tried a different approach. Not because Rust or F# are wrong, but because a clean alternative might exist when you aren’t constrained by millions of dependent crates.

This article explains that approach.


Why Exhaustiveness Matters

If you’ve never worked in a language with it, exhaustive matching might seem like polish — a compile-time to-do list. But exhaustiveness is about domain truth:

  • ✅ The type declares all possible states
  • ✅ The compiler ensures you handle all meaningful states
  • ✅ Changes surface immediately as compile errors

When the set of states expands, the world should notice.

Which raises the question:

How do we evolve APIs and preserve this integrity?


The Rust Approach

Imagine a pragmatic Rust enum for HTTP responses:

pub enum HttpResult {
    Success(String),
    ClientError,
    ServerError,
}

Consumers match:

match result {
    HttpResult::Success(json) => handle(json),
    HttpResult::ClientError => show_user_error(),
    HttpResult::ServerError => log("Server died"),
}

Now the library author wants to add redirects:

pub enum HttpResult {
    Success(String),
    ClientError,
    ServerError,
    Redirect { location: String }, // new
}

Boom — breaking change. Every consumer must update.

So Rust offers:

#[non_exhaustive]
pub enum HttpResult {
    Success(String),
    ClientError,
    ServerError,
}

And downstream code is forced into something like:

match result {
    HttpResult::Success(json) => handle(json),
    HttpResult::ClientError => show_user_error(),
    HttpResult::ServerError => log("Server died"),
    _ => handle_fallback(), // must exist
}

This solves dependency churn. But _ is a black hole for future behavior.

  • Does _ mean redirect?
  • Retry-later?
  • Unauthorized?
  • Something harmful?

It’s runtime surprise instead of compile-time honesty.


Koru’s Solution: Optional Branches

Koru keeps exhaustiveness strict by default. But library authors can mark certain branches optional — safe for evolution:

~event http_result {
    status: u16,
    body: []const u8,
}
| success { json: []const u8 }            // required
|? redirect { location: []const u8 }      // optional
|? retry_after { seconds: u32 }           // optional
| client_error {}                         // required
| server_error {}                         // required

The meaning is simple:

Required vs Optional Branches

Required branch (|):

  • Caller must handle it explicitly
  • Adding a new required branch is a breaking change

Optional branch (|?):

  • Caller may ignore it safely
  • Adding a new optional branch is non-breaking
  • Callers can opt into handling it when/if they care

Optional means truly optional — compiler stays silent unless you choose to care:

~fetch_json("https://api.example.com/data")
| success json        |> handleJson(json)
| client_error        |> showError("Client problem")
| server_error        |> log("Backend issue")
// optional branches ignored here — safely

If you want policy for future behaviors:

~fetch_json("https://api.example.com/data")
| success json            |> handleJson(json)
| client_error            |> showError("Client problem")
| server_error            |> log("Backend issue")
|?                        |> log("Optional branch taken")

No wildcard swallowing meaning. No runtime crashes when libraries evolve. The type author sets the rules — not the caller.


Evolution Becomes Explicit

Later, library adds authentication support:

|? unauthorized { login_url: []const u8 }

Old clients:

  • ✅ Still compile
  • ✅ Still behave rationally
  • ✅ Can adopt handling when ready

If auth becomes mandatory:

| unauthorized { login_url: []const u8 }   // required now

→ Compile errors everywhere → Callers adapt intentionally → No accidental ignorance of critical behavior

This is forward compatibility with honesty.


Difference in Philosophy

Rust/F# (for good reasons):

“Avoid breaking changes — even if we must hide unknown future states.”

Koru:

“If a change matters, it should matter at compile time. If it doesn’t, the type should say so explicitly.”

One shifts responsibility to callers. The other keeps responsibility with the API author.


Trade-offs and Fairness

Rust must optimize for:

  • huge dependency graphs
  • semver pragmatism
  • security upgrades under constraints
  • minimal friction for library evolution

Its solution is efficient and ecosystem-driven.

Koru is small and has no adoption (yet). We can:

  • accept louder breakage
  • keep semantics simple
  • let types express evolution intent directly

Those differing realities justify differing designs.

But from a language design perspective focused on correctness and clarity, optional branches cleanly resolve the safety/ergonomics tension:

  • ✔ Exhaustiveness preserved
  • ✔ API evolution supported
  • ✔ Intent explicitly encoded
  • ✔ No wildcards eating the future

This feels like the design we always wanted.


Gradual Adoption: Growing with Your APIs

Optional branches enable something powerful in large systems: incremental sophistication. Services can start simple and adopt new behaviors as they mature, without breaking changes forcing their hand.

Phase 1: Core Functionality

When a service first integrates with an API, it often only cares about the primary success/failure paths:

~payment.process(amount: 100, card: "4242...")
| success transaction   |> record_payment(transaction.id)
| failure error         |> notify_user(error)
// Optional branches (retry, auth_failure, etc.) silently ignored

The service works, it’s robust, and the code stays focused on what matters right now.

Phase 2: Targeted Enhancement

Months later, the team decides retry logic would improve user experience. They opt into just that behavior:

~payment.process(amount: 100, card: "4242...")
| success transaction   |> record_payment(transaction.id)
| failure error         |> notify_user(error)
|? retry handle         |> schedule_retry(handle.after_seconds)  // Now we care!
// Other optional branches still ignored

No breaking changes, no massive refactor - just surgical adoption of new capability.

Phase 3: Comprehensive Handling

As the service matures and business requirements evolve, it gradually embraces more sophisticated error handling:

~payment.process(amount: 100, card: "4242...")
| success transaction     |> record_payment(transaction.id)
| failure error           |> notify_user(error)
| retry handle            |> schedule_retry(handle.after_seconds)     // Promoted to required
|? auth_failure af        |> redirect_to_auth(af.login_url)   // New optional handling
|? fraud_review fr        |> flag_for_review(fr.case_id)        // Another new optional

The Organizational Benefits:

This gradual approach mirrors how real teams and products evolve:

For Small Teams:

Start with the simplest working version. Add complexity only when the pain point justifies it.

For Large Organizations:

Different services can adopt new features at different paces:

  • Critical payment service: Handles all branches from day one
  • Internal analytics service: Might never need auth_failure handling
  • Customer-facing portal: Adopts retry logic first, auth redirects later

For Ecosystem Evolution:

Library authors can introduce new behaviors without breaking existing integrations. Early adopters can opt in immediately, conservative services wait until the feature stabilizes.

No More “All or Nothing” Upgrades:

Traditional exhaustive matching forces painful migrations:

// Old: Working code
match result {
    Success => handle(),
    Error => log(),
}

// New: Must update everywhere immediately
match result {
    Success => handle(),
    Error => log(),
    Retry => schedule(),  // Breaking change!
    AuthFailure => redirect(),  // Breaking change!
}

With optional branches, evolution feels natural:

// Service A: Early adopter
| success |> handle()
| error |> log()
|? retry |> schedule()  // Opted in
|? auth_failure |> redirect()  // Opted in

// Service B: Conservative approach
| success |> handle()
| error |> log()
// Still works! Can adopt later when ready

The Migration Path Becomes a Conversation:

Instead of library authors forcing breaking changes, they can guide the ecosystem:

// Library v1.0: Basic cases
| success {}
| error {}

// Library v1.1: Add optional retry
| success {}
| error {}
|? retry { after_seconds }

// Library v1.2: Add optional auth
| success {}
| error {}
|? retry { after_seconds }
|? auth_failure { login_url }

// Library v2.0: Retry becomes critical
| success {}
| error {}
| retry { after_seconds }  // Now required
|? auth_failure { login_url }

Each service decides when and if to adopt each capability. The type system guides rather than forces.

Real-World Impact:

This approach transforms API evolution from a big bang disruption to a gradual conversation. Teams can:

  • Ship faster by starting with essential cases only
  • Reduce risk by adopting new behaviors incrementally
  • Stay current without constant breaking changes
  • Plan migrations based on business priorities, not compiler mandates

In a world of microservices and distributed teams, this flexibility isn’t just convenient—it’s essential for sustainable development at scale.


Final Thoughts

Koru is still early — one author, lots of AI assistance, zero production battle scars. Time will tell if this approach survives in messy real-world ecosystems. But conceptually:

  • The type is the truth.
  • Library authors know what should break callers.
  • Compile-time safety shouldn’t quietly degrade.

Optional branches let us keep the promise of exhaustiveness while avoiding dependency hell. They make API evolution a first-class decision, visible where it belongs: the type definition.

Rust and F# paved the path. Koru might just smooth one bump along the way.

And wouldn’t it be nice if handling the future felt this honest?