Tutorial

3. Results and recovery

Result[T, E] is a sum type with two constructors: Ok[T] for success and Err[E] for failure. Use it to return, store or collect an outcome that the caller will handle later. map transforms a successful value; flat_map sequences computations that can themselves return an Err.

Stored failures and raised errors are separate. If a callback raises, its error propagates to the caller. Use attempt to catch it in a Result.

Transform only the active branch

results.mojo Download
"""Domain errors remain distinct from raised callback failures."""
from fp.data import Result, Ok, Err, attempt, raise_on_err
from std.collections import StringDict
from std.testing import assert_equal

comptime Checked = Result[Int, String]

def positive(var value: Int) -> Checked:
    if value <= 0:
        return Err(String("expected a positive value"))
    return Ok(value)

def twice(var value: Int) -> Int:
    return value * 2

def success(value: Int) -> String:
    return "ok: " + String(value)

def failure(error: String) -> String:
    return "domain: " + error

def may_raise(var value: Int) raises String -> Int:
    if value == 21:
        raise String("callback failed")
    return value

def main() raises:
    var good = positive(21).map(twice)
    assert_equal(good.fold(success, failure), "ok: 42")
    print(good.fold(success, failure))
    var bad = positive(-1).map(twice)
    assert_equal(bad.is_err(), True)
    print(bad.fold(success, failure))
    var caught = False
    try:
        var value = positive(21).map(may_raise)
        print(value.fold(success, failure))
    except error:
        assert_equal(error, "callback failed")
        caught = True
        print("raised:", error)
    assert_equal(caught, True)

    # Explicitly capture the same native error as a Result value.
    var captured: Checked = attempt(may_raise, 21)
    assert_equal(captured.is_err(), True)
    caught = False
    try:
        _ = raise_on_err(captured^)
    except error:
        assert_equal(error, "callback failed")
        caught = True
    assert_equal(caught, True)

    # Forward a positional prefix and native keyword options.
    var calls = 0
    def scale(value: Int, /, var **options: Int) {mut calls} -> Int:
        calls += 1
        var factor = 1
        for entry in options.items():
            if entry.key == "factor": factor = entry.value
        return value * factor
    var scaled: Result[Int, Never] = attempt(scale, 7, factor=3)
    assert_equal(raise_on_err(scaled^), 21)
    assert_equal(calls, 1)
    var options = StringDict[Int]()
    options["factor"] = 4
    assert_equal(raise_on_err(attempt(scale, 7, **options^)), 28)
    assert_equal(calls, 2)

    # Changes made before a native error remain visible to the caller.
    var balance = 5
    def debit(mut value: Int, /, var **options: Int) raises String -> Int:
        for entry in options.items(): value -= entry.value
        if value < 0: raise String("overdrawn")
        return value
    var first_debit: Checked = attempt(debit, balance, amount=3)
    assert_equal(first_debit.is_ok(), True)
    assert_equal(balance, 2)
    var second_debit: Checked = attempt(debit, balance, amount=4)
    assert_equal(second_debit.is_err(), True)
    assert_equal(balance, -2)

Run from the repository root pixi run mojo run -I src docs/examples/results.mojo

Output

ok: 42
domain: expected a positive value
raised: callback failed

positive(21) returns Ok(21), and map(twice) turns it into Ok(42). positive(-1) returns Err, so twice never runs. fold borrows whichever payload is present and calls success or failure, which gives "ok: 42" and "domain: expected a positive value".

positive returns Ok(value) or Err(...) directly; both convert to the declared Checked result. map consumes its Result. For a named local, write value^.map(callback); the temporary that positive(...) returns is consumed directly. A callback that changes the type produces Result[U, E], and an Err passes through unchanged.

These transformations can be chained. Each calls its callback only for the case it handles:

  • flat_map is monadic bind: it passes a successful value to a callback that returns another Result, keeping the stored error type.
  • map_err changes the error payload.
  • or_else recovers from an error with another Result.

Code that is generic over algebra instances reaches the same methods through map[ResultFamily[E]].

Keep raised errors raised

may_raise is declared raises String and raises for 21. Calling positive(21).map(may_raise) propagates that error to the except block, which prints raised: callback failed. The shared String error type does not turn the raised error into a stored Err.

To convert it on purpose, call attempt(may_raise, 21). It returns Err("callback failed") with the declared String error type. raise_on_err consumes that Result and raises the error again. These two operations let you choose where failures move between values and exceptions.

Forward arguments through attempt

attempt calls its callback exactly once with the arguments you give it. The example shows each rule with its own values:

  • attempt(scale, 7, factor=3) forwards the positional 7 and the keyword factor=3 to the captured closure scale, which declares var **options: Int. It returns Result[Int, Never] holding 21, because scale does not raise, and the counter records one call.
  • A second call consumes a StringDict[Int] through **options^, returns 28, and brings the same counter to two.
  • A mut prefix keeps the callback's changes even when it raises. debit takes balance from 5 to 2, then to -2 while returning Err("overdrawn").

What attempt accepts:

  • Zero, one or two positional arguments, optionally followed by a homogeneous native keyword pack. The bridge function and the prefixes are positional-only.
  • Every positional argument, including those with defaults; omitted prefixes are not supported.
  • Borrowed resource arguments stay usable after the call. Move a named resource that the callback consumes with ^.
  • Conflicting mutable borrows are rejected as in native calls.
  • Ordinary fixed keyword parameters and generic positional packs are not supported. Bind those forms in a local native closure, when Mojo admits that closure's encoding.
  • A callback that returns an ordinary Result gets it nested inside Ok; attempt does not capture that Result's error.

Aggregate a sequence

collect_results consumes Result values in order. When the source runs out, it returns Ok(List[T]). At the first Err it returns that error straight away, without pulling another input, and native cleanup releases the successful prefix and the owned source. The configuration processor uses this to separate parsing from validation.

Generic transaction helpers

submit and transaction are generic functions whose results keep the callback's exact success and error types. The caller holds the Result after both helpers return and decides how to handle it.

attempt_generic.mojo Download
"""A generic transaction helper preserves state and returns the exact error."""
from fp.data import Result, attempt, raise_on_err
from std.testing import assert_equal

def transaction[A: Movable & Deinitable, R: Movable & Deinitable,
                X: Movable & Deinitable, //,
                F: def(mut A, /, var **kwargs: Int) raises X -> R](
    function: F, mut state: A, /, var **options: Int
) -> Result[R, X]:
    return attempt(function, state, **options^)

def submit[A: Movable & Deinitable, R: Movable & Deinitable,
           X: Movable & Deinitable, //,
           F: def(mut A, /, var **kwargs: Int) raises X -> R](
    function: F, mut state: A, /, var **options: Int
) -> Result[R, X]:
    return transaction(function, state, **options^)

@fieldwise_init
struct Declined(Movable):
    var balance: Int

def main() raises:
    var calls = 0
    def debit(mut balance: Int, /, var **charges: Int) raises Declined {mut calls} -> String:
        calls += 1
        for entry in charges.items(): balance -= entry.value
        if balance < 0: raise Declined(balance)
        return String(balance)
    var balance = 10
    var approved: Result[String, Declined] = submit(debit, balance, purchase=6, fee=1)
    var receipt: String
    try: receipt = raise_on_err(approved^)
    except error: raise Error("unexpected decline")
    assert_equal(receipt, "3")
    var declined: Result[String, Declined] = submit(debit, balance, purchase=5)
    var failure_balance = 0
    try: _ = raise_on_err(declined^)
    except error: failure_balance = error.balance
    assert_equal(failure_balance, -2)
    assert_equal(balance, -2)
    assert_equal(calls, 2)
    print("generic attempt: receipt=3; declined=-2; balance=-2; calls=2")

Run from the repository root pixi run mojo run -I src docs/examples/attempt_generic.mojo

Output

generic attempt: receipt=3; declined=-2; balance=-2; calls=2

The balance starts at 10. The first debit charges purchase=6 and fee=1, leaves the balance at 3, and returns the receipt "3". The second charges purchase=5, leaves the balance at -2, and returns Declined(-2). Catching the error does not roll back the debit. The same callback serves both calls, each transaction invokes it once, and the keyword values pass through both helpers. The helpers call the public attempt directly, with no callable object or result cast.

Generic transformations

mapped and forwarded consume a Result and borrow the same reusable callback. Each raising helper forwards X explicitly. The caller then chooses whether to sequence a successful value, translate a stored error, or recover.

result_transform_generic.mojo Download
"""Generic Result processing keeps domain failures separate from raised failures."""
from fp.data import Result, Ok, Err
from std.testing import assert_equal


def mapped[T: Movable & Deinitable, E: Movable & Deinitable,
           U: Movable & Deinitable, X: Movable & Deinitable, //,
           F: def(var T) raises X -> U](
    var value: Result[T, E], callback: F
) raises X -> Result[U, E]:
    return value^.map[X=X](callback)


def forwarded[T: Movable & Deinitable, E: Movable & Deinitable,
              U: Movable & Deinitable, X: Movable & Deinitable, //,
              F: def(var T) raises X -> U](
    var value: Result[T, E], callback: F
) raises X -> Result[U, E]:
    return mapped[X=X](value^, callback)


@fieldwise_init
struct Domain(Movable):
    var code: Int
@fieldwise_init
struct ProcessingFailure(Movable):
    var code: Int
@fieldwise_init
struct Notice(Movable):
    var code: Int


def accepted(var value: Int) -> Result[String, Domain]:
    if value < 10: return Err(Domain(40))
    return Ok(String("accepted"))

def explain(var error: Domain) -> Notice:
    return Notice(error.code + 100)

def recover(var error: Notice) -> Result[String, Never]:
    return Ok("notice " + String(error.code))


def main() raises:
    var calls = 0
    def process(var value: Int) raises ProcessingFailure {mut calls} -> Int:
        calls += 1
        if value < 0: raise ProcessingFailure(90)
        return value * 2
    for mode in range(3):
        var source = Result[Int, Domain](Err(Domain(7))) if mode == 1 else Result[Int, Domain](Ok(6 if mode == 0 else -1))
        var outcome: String
        try:
            var mapped_value = forwarded(source^, process)
            outcome = mapped_value^.flat_map(accepted).map_err(explain).or_else(recover).raise_on_err()
        except failure:
            comptime assert type_of(failure) == ProcessingFailure
            outcome = "raised " + String(failure.code)
        assert_equal(outcome, "accepted" if mode == 0 else "notice 107" if mode == 1 else "raised 90")
        print(outcome)
    assert_equal(calls, 2)

Run from the repository root pixi run mojo run -I src docs/examples/result_transform_generic.mojo

Output

accepted
notice 107
raised 90

The three inputs produce accepted, notice 107 and raised 90:

  • Ok(6) becomes 12 through process, and accepted returns Ok("accepted").
  • Err(Domain(7)) skips process and accepted. map_err(explain) turns it into a Notice with code 107, and or_else(recover) recovers it.
  • Ok(-1) makes process raise ProcessingFailure(90). The error leaves the chain at once and reaches except; map_err and or_else do not see it.

The counter ends at two, because only the two Ok inputs call process. The branch types that flat_map and or_else leave unchanged are part of their native callback signatures.

Next, select among structured values with data types and matching. For every operation, its ownership mode and exact error signature, see fp.data.