Error Propagation

Often the function that receives a failure is not the one that should decide what it means. Postfix ? hands it to the caller: on success, outcome? is the success value and evaluation carries on; on failure, the enclosing function fails at once with the same error.

propagate-expr = postfix-expr "?"
map-expr       = postfix-expr "?" "else" "(" binder "=>" expr ")"
binder         = identifier | "_"

? on a fallible

func ParseNumber(text: char8[..]) -> int ! DigitError {
    var value = 0;
    for i in 0..text.length {
        let c = text[i];
        if c < c8'0' || c > c8'9' {
            fail DigitError { position: i };
        }
        value = value * 10 + ((c - c8'0') as int);
    }
    return value;
}

func Area(width: char8[..], height: char8[..]) -> int ! DigitError {
    return ParseNumber(width)? * ParseNumber(height)?;
}

outcome? is shorthand for this match, with the failure arm leaving the function:

match outcome {
    .Success(value) => value,
    .Failure(error) => fail error
}
flowchart LR
    step["ParseNumber(width)?"] --> q{"Which channel?"}
    q -- ".Success(value)" --> go["the expression is value;<br/>Area carries on"]
    q -- ".Failure(error)" --> out["Area fails at once<br/>with the same error"]
  • The operand is evaluated exactly once. Several ? in one expression run from left to right, and the first failure leaves before the rest are evaluated.
  • Leaving through ? is an ordinary return: the error is captured first, then deferred statements run in reverse order and live locals are destroyed — including the completed fields of an aggregate whose construction the ? interrupted.
  • ? removes one level. The success value continues as it is, even when it is itself a fallible: in a (int32 ! ParseError) ! IoError, ? forwards only the outer IoError, and an inner failure continues as data.

Requirements

The function must be fallible

The failure leaves through the enclosing function's own failure channel, so that function must return U ! F (or ! F):

error: '?' propagates native fallible 'int ! DigitError', but the enclosing function returns 'int'
  note: '?' leaves through the outer failure channel of a fallible function
  help: declare the function's error channel, as in '-> T ! E', or handle the failure with 'match'

The success types need not match — only the error is passed on. An optional return type is not a target for a failure, and a fallible Main lets the top level of a program use ?.

The error must fit

? never converts an error. An error of type E leaves a function that fails with F only when:

E and FExample
the same typeDigitError into DigitError
E is a member of the sum FDigitError into DigitError | RangeError
E is a sum whose members are all in FDigitError | RangeError into DigitError | IoError | RangeError
func ParsePercent(text: char8[..]) -> int ! (DigitError | RangeError) {
    let value = ParseNumber(text)?;
    if value > 100 {
        fail RangeError { value: value };
    }
    return value;
}

Any other pair is rejected:

error: '?' propagates error type 'DigitError', but the enclosing function fails with 'SettingError'
  note: '?' moves an error into the outer failure only by identity, sum member injection, or subset widening; it never converts an error
  help: map the error to 'SettingError' with '? else (e => ...)', or match the value

Error mapping

value? else (e => mapper) propagates like value?, but converts the error first. On success the mapper never runs. On failure, e is bound to the complete error, the mapper runs once, and the function fails with its result:

func ParseWidth(text: char8[..]) -> int ! SettingError {
    let width = ParseNumber(text)? else (e => SettingError { name: "width", column: e.position + 1 });
    return width;
}

The mapper is the place to add context the error does not carry — here, the name of the setting and a 1-based column.

  • The parentheses hold one binder and one expression. The binder may be _ when the error is not needed and is copyable. A move-only error must be bound and moved, (e => Wrap(<-e)); _ there is error: the error 'Owned' cannot be discarded with '_' because it is move-only.
  • The mapped value must convert to the enclosing function's error type: ParseNumber(width)? else (e => 5) is error: the mapped error has type 'int', but the enclosing function fails with 'SettingError'.
  • A mapped value is never propagated again, so a mapper that returns a fallible is a type error, not a second ?.
  • The mapper may leave on its own with fail, return or a call to Panic. A return there takes the normal exit instead of failing.
  • The mapper is not a closure. It reads and moves the surrounding locals under the ordinary rules, on a path that always leaves the function; a local moved only inside the mapper is still owned on the continuing path.
  • ? else applies only to a native fallible, in a fallible function. On an optional it is error: '? else' maps the error of a native fallible, but the operand has type 'int?' — absence has no error to map.

A mapper that converts different members differently is a match, usually in a helper function:

func ToConfig(error: ParseError | IoError, line: int32) -> ConfigError {
    return match error {
        parse: ParseError => ConfigError { kind: 1, detail: parse.position * 100 + line },
        io: IoError => ConfigError { kind: 2, detail: io.code * 100 + line }
    };
}

func Load(text: int32, line: int32) -> int32 ! ConfigError {
    let value = Read(text)? else (e => ToConfig(e, line));
    return value + 1;
}

From absence to failure

? on an optional passes on absence, never an error; in a function that fails with F, it is rejected. When absence should be a failure, the coalescing fallback says which one:

func PriceOf(code: int) -> int? {
    return code == 1 ? .Some(250) : none;
}

func Total(code: int, count: int) -> int ! UnknownProduct {
    let price = PriceOf(code) ?? fail UnknownProduct { code: code };
    return price * count;
}

A function that returns U? ! F can pass on both: ? on a fallible forwards the failure, and ? on an optional returns a successful none.

Ownership

? and ? else consume their operand. A named copyable fallible is copied and stays usable; a named move-only one must be moved explicitly:

error: move-only value 'h' requires an explicit '<-' in propagation operand
  help: prefix the outcome with '<-', as in '(<-h)?'

A borrowed fallible is never an operand; match it instead. The continuing value and the outgoing error are moved with their move operations.

See also