This tutorial is aspirational. For updated and verified content, see the Learn section which contains working examples and regression tests.
In Koru, there are no "errors" - just branches of reality that must all be handled explicitly.
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:
// 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: 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 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?
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.
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:
Wildcard patterns are tempting because they're convenient:
But convenience today becomes silent bugs tomorrow when the library evolves.
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!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."
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})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.
Consider reading a file. There are three predictable outcomes:
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.
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.
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.
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.
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 |> _| Aspect | Exceptions | Go | Rust Result | Koru |
|---|---|---|---|---|
| Control Flow | Hidden | Explicit | Explicit | Explicit |
| Compiler Enforced | No | No | Yes | Yes |
| Can Discard Branches | Yes (implicit) | Yes (_ assignment) | Yes (_ pattern) | No - must handle all |
| Safe API Evolution | No | No | No (wildcards) | Yes - forces updates |
| Granular Errors | Runtime only | Manual | Lost by ? | Always preserved |
| Special Case Errors | Yes | Yes | Yes | No - all branches equal |
| Visual Clarity | Poor | Verbose | Good | Excellent |
| Runtime Overhead | High | Low | Zero | Zero |
Look at the code and you see exactly where execution can go. No invisible exceptions, no hidden early returns, no control flow magic.
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.
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.
Event continuations make it natural to chain operations while preserving granular error information at every step. No need for monad transformers or special combinators.
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.
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.
Now that you understand how Koru handles branches, explore related concepts: