fforj

Result: errors as values

Result<E, T> is an operation that either succeeded with a T or failed with an E. Both outcomes are ordinary values, so failure handling becomes plain code you can read, type-check, and test, instead of a control-flow surprise three stack frames up.

Errors are typed. Instead of String messages or a grab-bag Exception, model the ways an operation can fail as a small sealed hierarchy:

sealed interface ParseError {
    record NotANumber(String raw) implements ParseError {}
    record NotPositive(int value) implements ParseError {}
}
static Result<ParseError, Integer> parsePositive(String raw) {
    int n;
    try {
        n = Integer.parseInt(raw);
    } catch (NumberFormatException e) {
        return Result.err(new ParseError.NotANumber(raw));
    }
    return n > 0 ? Result.ok(n) : Result.err(new ParseError.NotPositive(n));
}

Pattern matching is the API

Result is a sealed interface with two record cases, Ok and Err. A switch over it is exhaustive: handle both cases or the compiler stops you. Combinators exist for chains, but this is the primary interface. There is no method to memorize, just the language.

String message = switch (parsePositive("-3")) {
    case Result.Ok<ParseError, Integer> ok -> "got " + ok.value();
    case Result.Err<ParseError, Integer> err -> switch (err.error()) {
        case ParseError.NotANumber(String raw) -> raw + " is not a number";
        case ParseError.NotPositive(int n) -> n + " is not positive";
    };
};

assertEquals("-3 is not positive", message);

Transform without unwrapping

map transforms a success and leaves an error untouched; flatMap chains another fallible step; recover turns an error back into a success. Chains stop doing work at the first Err. No null checks, no nesting.

Result<ParseError, Integer> doubled = parsePositive("21").map(n -> n * 2);
assertEquals(Result.ok(42), doubled);

Result<ParseError, Integer> chained = parsePositive("6")
        .flatMap(n -> parsePositive(String.valueOf(n - 7)))   // -1 -> Err
        .map(n -> n * 100);                                    // never runs
assertEquals(Result.err(new ParseError.NotPositive(-1)), chained);

assertEquals(Result.ok(0), chained.recover(e -> Result.ok(0)));

Wrap code that throws

The world is full of APIs that throw. attempt runs a block, catches anything thrown, and maps it into your error type. Several dependent throwing calls compose as straight-line code, and the first throw short-circuits the rest.

Result<String, Integer> r = Result.attempt(() -> {
    int a = mustParse("12");        // throws on bad input
    int b = mustParse("oops");      // throws here...
    return a + b;                   // ...so this never runs
}, t -> "failed: " + t.getMessage());

assertEquals(Result.err("failed: not a number: oops"), r);
static int mustParse(String raw) throws IOException {
    try {
        return Integer.parseInt(raw);
    } catch (NumberFormatException e) {
        throw new IOException("not a number: " + raw);
    }
}

Sequence Result-returning steps with binding

When steps already return Result, binding gives you do-notation: bind.on(...) hands back the raw success value, and the first Err aborts the whole block and becomes its result. No flatMap towers, and every earlier value stays in scope for later steps.

The error type is consumed covariantly, so each step can return its own subtype of the block's error. The block unifies them for you — no mapErr per step.

Here each step fails with its own subtype of BankError, and the block is typed Result<BankError, Integer>:

sealed interface BankError {
    record NoSuchAccount(String id) implements BankError {}
    record WrongPin() implements BankError {}
    record OverDailyLimit(int requested, int remaining) implements BankError {}
}
static Result<BankError.NoSuchAccount, String> findAccount(String id) {
    return id.isEmpty() ? Result.err(new BankError.NoSuchAccount(id)) : Result.ok("acc-" + id);
}
static Result<BankError.WrongPin, String> verifyPin(String account, String pin) {
    return pin.equals("1234") ? Result.ok(account) : Result.err(new BankError.WrongPin());
}
static Result<BankError.OverDailyLimit, Integer> withdraw(String account, int amount) {
    int limit = 500;
    return amount <= limit ? Result.ok(amount) : Result.err(new BankError.OverDailyLimit(amount, limit));
}
// Each step returns a distinct BankError subtype; no per-step mapErr needed.
Result<BankError, Integer> cash = Result.binding(bind -> {
    var account  = bind.on(findAccount("alice"));          // Result<NoSuchAccount, String>
    var verified = bind.on(verifyPin(account, "1234"));    // Result<WrongPin, String>
    return bind.on(withdraw(verified, 200));               // Result<OverDailyLimit, Integer>
});
assertEquals(Result.ok(200), cash);

// Wrong PIN short-circuits and propagates the WrongPin subtype as BankError.
Result<BankError, Integer> denied = Result.binding(bind -> {
    var account  = bind.on(findAccount("alice"));
    var verified = bind.on(verifyPin(account, "9999"));    // Err(WrongPin) -> aborts here
    return bind.on(withdraw(verified, 200));
});
assertEquals(Result.err(new BankError.WrongPin()), denied);

Guards without values: ensure

Not every rule produces a value; some just have to hold. bind.ensure reads like an assertion but fails like everything else here: as a typed Err that short-circuits the block.

Result<String, Integer> withdrawal = Result.binding(bind -> {
    int balance = bind.on(Result.<String, Integer>ok(70));
    bind.ensure(balance >= 100, () -> "insufficient funds: " + balance);
    return balance - 100;                  // never reached
});

assertEquals(Result.err("insufficient funds: 70"), withdrawal);

Bridge to and from Optional

Optional models absence; Result models failure with a reason. The bridges keep the line crisp: lifting an empty Optional into a Result asks you to name the failure, because emptiness alone doesn't carry one.

Optional<NonEmptyList<Integer>> none = NonEmptyList.fromList(List.of());

Result<String, NonEmptyList<Integer>> r =
        Result.fromOptional(none, () -> "no candidates to rank");

assertEquals(Result.err("no candidates to rank"), r);
assertEquals(Optional.of("no candidates to rank"), r.errValue());

This page is generated from a test file. Read or improve it on GitHub.