Result Is a Half-Measure

· 10 min read

Result Is a Half-Measure

Every modern language has discovered the same thing: functions can fail, and the return type should say so.

Rust calls it Result<T, E>. Haskell calls it Either a b. F# and OCaml call it result. Swift calls it Result<Success, Failure>. They all arrived at the same design: a sum type where the variants are generic containers (Ok/Err, Right/Left) and the details are data payloads inside them.

Then each language bolted on syntax to make the happy path ergonomic:

// Rust: ? operator
let user = get_user(id)?;
let perms = get_permissions(&user)?;
-- Haskell: do notation
user <- getUser id
perms <- getPermissions user
// F#: computation expression
let! user = getUser id
let! perms = getPermissions user

This is good. This is genuinely better than exceptions, error codes, or null. The type system knows about failure. The compiler can check that you handled it.

But it’s only half of the outcome story. Every one of these languages stopped at the same point and left the same problems unsolved.

Problem 1: The Details Are Data, Not Types

Real operations don’t have two outcomes. They have as many outcomes as the domain requires.

What happens when you read a file?

fn read_file(path: &str) -> Result<String, Error>

Ok or Err. The type system sees two variants. The compiler checks two things: did you handle Ok? Did you handle Err? If yes, you’re done.

Every language knows this isn’t enough. So you make the error type richer:

enum FileError {
    NotFound,
    PermissionDenied,
    DiskFull,
    InvalidPath(String),
}

fn read_file(path: &str) -> Result<String, FileError>

Now you have four failure modes. But here’s the problem: the compiler’s exhaustiveness check stops at the container variants.

match read_file("config.txt") {
    Ok(contents) => process(contents),
    Err(_) => log("something went wrong"),  // compiles fine
}

The exhaustiveness check is on Result’s two variants — Ok and Err. That’s where it stops. Everything inside Err is a data payload. The compiler already signed off. Whether you match on FileError::NotFound or wildcard the whole thing with Err(_), the compiler is equally satisfied. Even if you do fully match on FileError, the language doesn’t make it structural — you had to opt in with nested matching.

The same is true in every language:

-- Haskell: Left is Left, the compiler doesn't check what's inside
case result of
  Right contents -> process contents
  Left _ -> log "something went wrong"        -- compiles fine
// F#: Error is Error
match result with
| Ok contents -> process contents
| Error _ -> log "something went wrong"       // compiles fine

You can choose to match the specific errors:

match read_file("config.txt") {
    Ok(contents) => process(contents),
    Err(FileError::NotFound) => use_defaults(),
    Err(FileError::PermissionDenied) => escalate(),
    Err(FileError::DiskFull) => panic!("disk full"),
    Err(FileError::InvalidPath(p)) => log_bad_path(p),
}

But nothing forces you. The rich error enum is voluntary discipline. The compiler sees Ok and Err and moves on. The details are data — runtime values inside a container the compiler has already approved.

In Koru, the details are branches:

~read_file(path: "config.txt")
| contents c |> process(data: c.text)
| not_found  |> use_defaults()
| denied     |> escalate()
| disk_full  |> panic(msg: "disk full")
| bad_path p |> log_bad_path(path: p.value)

Each outcome is a top-level branch. The compiler sees five things to handle, not two. In Koru, branch names and payloads are part of the event’s type signature. not_found isn’t data inside a container — it’s a variant the compiler individually tracks. You can’t Err(_) your way past it. There is no Err to hide behind.

This is the structural difference. In Rust, Haskell, F#, OCaml, Swift — every one of them — the error details are a data payload. The programmer can inspect them or ignore them. The compiler’s job ended at the two-variant check.

In Koru, the error details are compiler food. They’re part of the type. The exhaustiveness checker sees each one. The compiler’s job doesn’t end until every branch has a continuation.

Problem 2: The ? Collapse

Rust’s ? operator is monadic bind for Result. It’s elegant: try the operation, and if it fails, propagate the error upward. But watch what happens when you chain operations with different error types:

fn process(id: u32) -> Result<Output, AppError> {
    let user = get_user(id)?;              // Result<User, DbError>
    let perms = get_permissions(&user)?;   // Result<Perms, AuthError>
    let file = read_config(&perms)?;       // Result<Config, IoError>
    Ok(transform(user, file))
}

Three operations. Three different error types. ? can’t propagate DbError into a function that returns AppError unless you implement conversion:

impl From<DbError> for AppError { ... }
impl From<AuthError> for AppError { ... }
impl From<IoError> for AppError { ... }

You’re writing boilerplate to collapse three meaningful error types into one generic one. The call site that catches AppError now has to match on variants that could have come from anywhere. “Was this a database error from get_user or from some other database call?” The provenance is lost.

Some Rust codebases give up and use anyhow::Error — a type-erased error that can hold anything. At that point, you’ve reinvented exceptions with extra steps.

In Koru, each event declares its own outcomes. There’s nothing to collapse:

~get_user(id: 4)
| ok u |>
    get_permissions(user: u)
    | ok p |>
        read_config(perms: p)
        | ok c |> transform(user: u, config: c)
            | done d |> ...
        | io_error e |> ...
    | denied |> ...
| not_found |> ...
| db_error e |> ...

Every failure is handled at the exact point where it can occur. There’s no conversion. There’s no collapse. You know that db_error came from get_user because it’s syntactically nested under get_user. The structure of the code is the provenance.

Problem 3: Option and Result Are Different Types

Rust has Result<T, E> for operations that can fail with an error, and Option<T> for operations that might not have a value. These are separate types.

Want to use ? on an Option inside a function that returns Result? You need to convert:

fn get_role(user: &User) -> Result<Role, AppError> {
    let role = user.role.ok_or(AppError::NoRole)?;   // Option → Result
    Ok(role)
}

.ok_or() bridges Option to Result. Every time. At every call site. Because the language has two separate types for the same concept: “this operation has more than one outcome.”

Haskell has the same split: Maybe a vs Either a b. F# has Option<'T> vs Result<'T, 'E>. Each pair requires different combinators, different monad instances, different syntax in some cases.

In Koru, there is no split. An event that might not have a value:

~lookup(key: "email")
| found f |> use(f.value)
| missing |> use_default()

An event that can fail with an error:

~read_file(path: "config.txt")
| contents c |> process(c.text)
| not_found  |> use_defaults()

Same mechanism. Same syntax. Same composition rules. There is no Option vs Result distinction because the distinction never existed — it was an artifact of limiting yourself to two variants.

Problem 4: Monad Transformers

Haskell solves the composition problem with monad transformers. If your computation needs both IO and Either, you stack them:

type App a = ExceptT AppError (ReaderT Config IO) a

runApp :: Config -> App a -> IO (Either AppError a)
runApp config app = runReaderT (runExceptT app) config

This works. It’s also the point where most working programmers look at Haskell and walk away. The type signatures become nested transformer stacks. Lifting between layers requires lift or liftIO. The abstraction cost isn’t at runtime — it’s in the programmer’s head.

F# computation expressions are cleaner but still require separate builders for each effect type. OCaml’s let* bindings need separate modules.

The underlying problem is the same everywhere: Result/Either/Option are library types. They don’t compose with each other or with other effects without explicit bridging. The language provides one or two special mechanisms (?, do, let!) and everything else is manual.

In Koru, there’s one mechanism. Events compose through continuation syntax. There’s nothing to bridge because there’s nothing to bridge between.

~read_file(path: "config.txt")
| contents c |>
    parse_json(data: c.text)
    | valid v |>
        lookup(config: v.parsed, key: "database_url")
        | found f |> connect(url: f.value)
            | connected db |> ...
            | refused r |> ...
        | missing |> ...
    | malformed m |> ...
| not_found |> ...
| denied |> ...

File I/O, parsing, lookup, database connection. Four different “effect types” in any other language. Here they’re all events with branches. The nesting is the composition.

The Pattern

Every language in this post discovered the same insight: function return types should encode failure as data, not as an exception.

Good insight. But they all encoded it the same way: a sum type where the variants are generic containers and the details are data payloads inside them. The compiler checks the containers. The programmer is responsible for the details.

The number of containers doesn’t matter. Even if Rust had Result3<T, E1, E2> or Result5<T, E1, E2, E3, E4>, you’d have the same problem. The variants are where the compiler stops. Everything inside them is data the programmer can inspect or ignore.

The limitations are structural:

LimitationCause
Error details are uncheckedCompiler exhaustiveness stops at the container variants, not the payloads inside
Error type conversion boilerplate?/do/let! requires uniform types across a chain
Option/Result splitDifferent container shapes for the same concept (“more than one outcome”)
Monad transformers / computation expressionsLibrary types don’t compose without explicit bridging

These aren’t implementation bugs. They’re consequences of the same design decision: encoding outcomes as data inside generic containers, instead of making each outcome a compiler-visible variant.

Put differently: these languages type values. They don’t type control flow. The type system models what data looks like. It doesn’t model what can happen next. The type checker stops at the container boundary and trusts the programmer with everything inside.

That’s the missing half.

What Koru Does Differently

Koru makes outcomes explicit. It pushes control flow into the type system.

Every event declares its outcomes as named, typed branches. Not as data inside containers — as the variants themselves. The compiler sees each one individually. Exhaustiveness checking doesn’t stop at generic wrappers and trust you with the rest. It extends into the flow — every branch, every outcome, every path the program can take.

This doesn’t break at any multiplicity. Two outcomes, five outcomes, twelve outcomes — each one is a branch the compiler tracks. The event signature is the type. The branches are compiler food, not data payloads.

// Two outcomes? Fine.
~lookup(key: "x")
| found f |> ...
| missing |> ...

// Five outcomes? Same mechanism, same guarantees.
~read_file(path: "config.txt")
| contents c |> ...
| not_found  |> ...
| denied     |> ...
| disk_full  |> ...
| bad_path p |> ...

// Twelve outcomes? Still scales. Still checked. Still one level.

Result<T, E> is what you build when your language puts the boundary between “compiler-checked” and “programmer-checked” at the container level. Koru puts it at the outcome level.

It was always a half-measure. A good half-measure. But half.