⚠️

Dubious Content

This tutorial is aspirational. For updated and verified content, see the Learn section which contains working examples and regression tests.

Rethinking Error Handling: Branches of Reality

In Koru, there are no "errors" - just branches of reality that must all be handled explicitly.

The Problem with Traditional Error Handling

Most programming languages treat errors as something special - something exceptional that needs different handling than "normal" results. This leads to three common patterns, each with serious flaws:

Exceptions: Hidden Control Flow

// JavaScript/Python: Hidden control flow
try {
    const data = readFile("config.txt");
    const parsed = parseJSON(data);
    const validated = validate(parsed);
    return process(validated);
} catch (e) {
    // Which function threw? What type of error?
    // No way to know without runtime information
    console.error("Something failed:", e);
}

Exceptions can be thrown from anywhere in the call stack. When you catch an exception, you often can't tell which function failed or what kind of failure occurred without inspecting runtime error messages. The control flow is invisible in the code.

Go: Easy to Ignore

// Go: Manual checking, easy to forget
func processFile(path string) (Data, error) {
    data, err := readFile(path)
    if err != nil {
        return Data{}, err
    }

    parsed, err := parseJSON(data)
    if err != nil {
        return Data{}, err  // Lost context: read or parse error?
    }

    return process(parsed), nil
}

Go's error returns are better - at least errors are visible. But they're easy to ignore, require manual checking at every step, and error context gets lost as errors bubble up. Was it a read error or a parse error? You lose that information.

Rust: Better, But Still Special-Casing

// Rust: Better, but still treats errors specially
fn process_file(path: &str) -> Result<Data, Error> {
    let data = read_file(path)?;  // Early return on error
    let parsed = parse_json(data)?;
    let validated = validate(parsed)?;
    Ok(process(validated))
}

// Caller must match or propagate
match process_file("config.txt") {
    Ok(data) => handle_data(data),
    Err(e) => handle_error(e),  // All errors collapsed
}

Rust's Result type is the best of the traditional approaches - errors are typed and must be handled. But "success" and "error" are still treated as fundamentally different categories. The ? operator collapses all errors into a single path, losing granularity.

All of these approaches share a fundamental assumption: errors are exceptional cases that need special treatment. But what if that assumption is wrong?

The Silent Killer: Discard Patterns

Even Rust, with its vaunted type system and exhaustiveness checking, has a fatal flaw: the wildcard pattern _ completely undermines safety. This is especially dangerous during API evolution.

The Problem

When a library adds a new variant to an enum, code using wildcard patterns _ continues to compile, but now silently discards the new case. The compiler can't help you because you explicitly told it "I don't care about other cases."

// Rust: Wildcard pattern SILENTLY breaks exhaustiveness
enum FileResult {
    Success(String),
    NotFound,
    IOError(String),
}

// Today's code
match read_file("config.txt") {
    Success(data) => process(data),
    NotFound => use_defaults(),
    _ => println!("error"),  // Catches IOError
}

// Library adds PermissionDenied variant tomorrow
enum FileResult {
    Success(String),
    NotFound,
    IOError(String),
    PermissionDenied,  // NEW!
}

// Your code STILL COMPILES but now silently discards PermissionDenied!
// The wildcard _ matches it, hiding the new case
match read_file("config.txt") {
    Success(data) => process(data),
    NotFound => use_defaults(),
    _ => println!("error"),  // Now also catches PermissionDenied
}

This is insidious because:

  • Your code compiles without warnings
  • Tests might pass (if you don't test the new case)
  • The bug only surfaces in production when the new case occurs
  • You lose the very exhaustiveness checking that makes Rust's error handling better than Go

Why Developers Use Wildcards

Wildcard patterns are tempting because they're convenient:

  • "I only care about Success, treat everything else as an error"
  • "I just want this to compile quickly"
  • "I'll come back and handle other cases later" (spoiler: they never do)

But convenience today becomes silent bugs tomorrow when the library evolves.

Koru: No Escape Hatch

Koru doesn't have wildcard patterns for branch handlers. Every branch must be handled explicitly by name. When a library adds a new branch, your code fails to compile until you update every call site.

// Koru: IMPOSSIBLE to discard branches
~event read { path: []const u8 }
| success { contents: []const u8 }
| notfound {}
| ioerror { msg: []const u8 }

// Every branch must be handled explicitly - NO wildcards
~read(path: "config.txt")
| success s |> process(contents: s.contents)
| notfound |> useDefaults()
| ioerror e |> log(msg: e.msg)

// Library adds permission_denied branch
~event read { path: []const u8 }
| success { contents: []const u8 }
| notfound {}
| ioerror { msg: []const u8 }
| permission_denied {}  // NEW!

// Your code now FAILS TO COMPILE until you handle it
~read(path: "config.txt")
| success s |> process(contents: s.contents)
| notfound |> useDefaults()
| ioerror e |> log(msg: e.msg)
// COMPILER ERROR: Missing handler for permission_denied branch!

The Koru Guarantee

API evolution is safe by default. When upstream adds a branch, downstream must update. There's no way to accidentally ignore new cases. The inconvenience of updating call sites is vastly outweighed by the safety of knowing every outcome is handled.

This is what "exhaustiveness checking" should mean: not just "you handled all cases that exist today," but "you will be forced to handle all cases that exist tomorrow."

Koru's Philosophy: All Branches Are Equal

In Koru, we reject the idea that some outcomes are "errors" and others are "success." An event simply declares all possible outcomes, and you must handle each one explicitly. None is more special than another.

// A value can be positive, negative, or zero
// None of these is an "error" - they're all valid outcomes
~event check { value: i32 }
| positive { n: i32 }
| zero {}
| negative { n: i32 }

~proc check {
    if (value > 0) return .{ .positive = .{ .n = value } };
    if (value < 0) return .{ .negative = .{ .n = value } };
    return .{ .zero = .{} };
}

// Every branch must be handled explicitly
~check(value: 42)
| positive p |> std.debug.print("Positive: {}\n", .{p.n})
| zero |> std.debug.print("Zero\n", .{})
| negative n |> std.debug.print("Negative: {}\n", .{n.n})

Key Insight

Is negative an "error"? Is zero special? No - they're all equally valid outcomes of checking a number. The same philosophy applies to file I/O, database queries, network requests, and everything else.

Real-World Example: File I/O

Consider reading a file. There are three predictable outcomes:

  • success - The file exists and was read successfully
  • notfound - The file doesn't exist
  • ioerror - Something went wrong (permissions, disk error, etc.)

None of these is "exceptional" - they're all expected, predictable realities of file systems. In Koru, you handle all three explicitly:

// File I/O has three equally valid outcomes
~event read { path: []const u8 }
| success { contents: []const u8 }
| notfound {}
| ioerror { msg: []const u8 }

// All three branches must be handled
~read(path: "config.txt")
| success s |> processFile(contents: s.contents)
| notfound |> useDefaults()
| ioerror e |> std.debug.print("IO error: {s}\n", .{e.msg})

The compiler guarantees you've handled all three cases. There's no way to forget to check for notfound, no hidden exception that can crash your program.

Deep Error Handling: Multi-Step Pipelines

Real applications chain multiple operations together, where each step can succeed or fail. Koru's continuation syntax makes this explicit at every level:

// Real apps: 4 steps, each can succeed or fail
// Every step handles both branches at its level
~step1()
| ok s1 |> step2(value: s1.value)
    | ok s2 |> step3(value: s2.value)
        | ok s3 |> step4(value: s3.value)
            | ok _ |> log(msg: "Success!")
                | done |> _
            | failed f |> log(msg: f.reason)
                | done |> _
        | failed f |> log(msg: f.reason)
            | done |> _
    | failed f |> log(msg: f.reason)
        | done |> _
| failed f |> log(msg: f.reason)
    | done |> _

Look at the structure: every ok branch continues to the next step. Every failed branch is handled at that level, with full context about which step failed.

Compare to Try/Catch

In exception-based languages, a single catch block receives failures from all four steps, losing information about where the failure occurred. You have to parse error messages at runtime to figure out what went wrong.

Complete Example: File Processing Pipeline

Here's a realistic pipeline using Koru's module system: read a file, parse its lines, transform the text, and write the output.

// Complete I/O pipeline with directory imports
~import "lib/io"

~io.file:read(path: "input.txt")
| success s |> io.parse:lines(text: s.contents)
    | parsed p |> io.transform:uppercase(text: s.contents)
        | transformed t |> io.file:write(path: "output.txt", data: t.result)
            | written w |> _
            | ioerror e |> _
| notfound n |> _
| ioerror e |> _

Every step can fail in different ways, and every failure mode is handled explicitly. The read event can return notfound or ioerror. The write event can return ioerror. All branches are visible, all branches are handled.

Side-by-Side: Koru vs. Traditional Approaches

Let's compare the same file processing logic across different error handling paradigms:

// Koru: Every outcome is explicit and composable
~read(path: "config.txt")
| success s |> parse(data: s.contents)
    | valid v |> validate(obj: v.parsed)
        | ok validated |> process(data: validated.obj)
            | done |> _
        | invalid i |> log(msg: i.reason)
            | done |> _
    | malformed m |> log(msg: "Parse error")
        | done |> _
| notfound |> useDefaults()
    | done |> _
| ioerror e |> log(msg: e.msg)
    | done |> _
AspectExceptionsGoRust ResultKoru
Control FlowHiddenExplicitExplicitExplicit
Compiler EnforcedNoNoYesYes
Can Discard BranchesYes (implicit)Yes (_ assignment)Yes (_ pattern)No - must handle all
Safe API EvolutionNoNoNo (wildcards)Yes - forces updates
Granular ErrorsRuntime onlyManualLost by ?Always preserved
Special Case ErrorsYesYesYesNo - all branches equal
Visual ClarityPoorVerboseGoodExcellent
Runtime OverheadHighLowZeroZero

The Benefits of Branches, Not Errors

No Hidden Control Flow

Look at the code and you see exactly where execution can go. No invisible exceptions, no hidden early returns, no control flow magic.

Exhaustiveness Checking

The compiler guarantees you've handled every possible outcome. Add a new branch to an event? Every call site must be updated to handle it.

Errors Are Just Data

There's no special "error type" or exception object. Branches carry typed data just like any other value. failed branches get the same treatment as success branches.

Natural Composition

Event continuations make it natural to chain operations while preserving granular error information at every step. No need for monad transformers or special combinators.

Zero Runtime Overhead

Since everything is resolved at compile time through Zig's comptime system, there's no runtime cost. No exception unwinding, no dynamic dispatch, no allocations.

A Different Way of Thinking

The shift from "error handling" to "branch handling" is more than just syntax. It's a fundamental change in how we think about program behavior:

Traditional thinking: "This function returns a value, but it might throw an exception or return an error."

Koru thinking: "This event has three possible outcomes. I must handle all three."

When you stop treating some outcomes as "errors" and start treating them as equally valid branches of reality, your code becomes clearer, more maintainable, and more robust. You're not handling exceptions - you're handling reality.

Next Steps

Now that you understand how Koru handles branches, explore related concepts: