By Christian Findlay

Error Handling

Osprey has no language-level exceptions. Every fallible language operation uses Result or a statically handled algebraic effect. A raw foreign status is ABI data and MUST be translated at the safe Osprey boundary.

Arithmetic is total. An arithmetic expression always evaluates to a defined value of its static type: it can never trap, panic, wrap silently, or produce an unspecified value, and a fault that cannot be proven impossible MUST be discharged — or the program is rejected at compile time. The clauses and conformance obligations are ARITH-TOTAL.

The two language flavors share these semantics. Examples show both surfaces where their syntax differs.

The Result Type

type Result<T, E> = Success { value: T } | Error { message: E }

The compiler rejects direct access to the contained value and never implicitly converts Result<T, E> to T. Callers must pattern-match the Result (see Pattern Matching) or use ?: to supply a fallback (Result Default). Assignments, arguments, comparisons, interpolation, and declared plain return types do not erase the wrapper (Result Preservation).

let result = someFunctionThatCanFail()
let value  = someFunctionThatCanFail() ?: 0

match result {
    Success { value }   => print("Success: ${value}")
    Error   { message } => print("Error: ${message}")
}
result = someFunctionThatCanFail
value  = someFunctionThatCanFail ?: 0

match result
    Success value   => print "Success: ${value}"
    Error   message => print "Error: ${message}"

Arithmetic — [ARITH-CHECKED]

Arithmetic is total. Every arithmetic expression evaluates to a defined value of its static type, and integer +, -, *, %, unary -, and abs have type int; / has type float. There is no Result in an arithmetic type.

An operation whose mathematical result is unrepresentable — integer overflow, a zero divisor — performs an operation of the compiler-declared Arith effect, and the handler the region installed substitutes the value its policy chooses. The full guarantee, the operation signatures, and the policy forms are Arithmetic Effects.

Operator int, int float, float int, float / float, int
+ - * int float float (int promoted before operation)
/ float float float
% int float float (int promoted)

/ always yields float. Unary integer - has type int -> int; unary float - has type float -> float. Floating-point +, -, *, and unary - are plain IEEE-754 operations. Integer -9223372036854775808 % -1 yields 0; the representable remainder is produced without executing LLVM's faulting srem case. The builtins checkedAdd, checkedSub, and checkedMul return Result<int, Error> and are the explicit value-level spelling for code that wants overflow as data.

let sum       = 1 + 3      // int
let quotient  = 10 / 3     // float
let remainder = 10 % 3     // int
let mixed     = 10 + 5.5   // float
let checked   = checkedAdd(a: 1, b: 3)   // Result<int, Error>
sum       = 1 + 3      // int
quotient  = 10 / 3     // float
remainder = 10 % 3     // int
mixed     = 10 + 5.5   // float
checked   = checkedAdd (1, 3)   // Result<int, Error>

Negated Literals — [ARITH-NEG-LITERAL]

A negated numeric literal is folded at parse time into a literal of the opposite sign, so -1 has type int and -1.5 has type float. The fold is total: the one overflowing case, -(-9223372036854775808), is reachable only by double negation and performs Arith.overflow like any other unrepresentable negation.

Arithmetic policies

A region states its arithmetic policy once, in a handler, instead of at every operation. Wrapping consumes the operation's two's-complement payload; a fault-recording policy writes handler-owned state and lets the boundary decide.

fn djb2(bytes) = bytes |> fold(5381, fn(h, b) => h * 33 + b)

let digest = handle Arith
    overflow _ _ _ wrapped => wrapped
do djb2(payload)
djb2 bytes = bytes |> fold 5381 (fn (h, b) => h * 33 + b)

digest =
    handle Arith
        overflow _ _ _ wrapped => wrapped
    in djb2 payload

An unhandled arithmetic operation is a compile error naming the effect and operation, not a runtime surprise:

unhandled effect operations at program entry: Arith.overflow; add a matching handle

toString Format

A Result formats as Success(<value>) or Error(<message>):

print(toString(15 / 3))          // "5.0"  — division is always float
print(toString(checkedAdd(2, 3)))   // "Success(5)"
print(toString(checkedMul(4294967296, 4294967296)))  // "Error(integer overflow)"
print (toString (15 / 3))          // "5.0"  — division is always float
print (toString (checkedAdd (2, 3)))   // "Success(5)"
print (toString (checkedMul (4294967296, 4294967296)))  // "Error(integer overflow)"

Error Payload Propagation — [ERR-PAYLOAD]

When a function produces Error { message: E }, the value bound to message in the caller's match arm MUST be the exact E value that the producer wrote, not a placeholder, static string, or default.

match split("abc", "") {
    Success { value }   => forEachList(value, print)
    Error   { message } => print(message)   // "split: separator must not be empty"
}
match split ("abc", "")
    Success value   => forEachList value print
    Error   message => print message   // "split: separator must not be empty"

This requirement applies to every Result-returning operator, builtin, and user function, including failure-preserving arithmetic chains.

Discarding a Result — [ERROR-RESULT-DISCARD]

A Result left in statement position is rejected, because dropping it drops the error channel with it — the one thing the type exists to make visible:

an unhandled `Result` cannot be discarded; use `match` or `?:`
fn go() -> int = {
    risky(1)                    // rejected: the Error arm would vanish
    let _ = risky(1)            // rejected: `_` cannot consent to losing an error
    let ok = risky(1) ?: 0      // accepted: the failure has an answer
    ok
}

let _ = is the sanctioned way to discard an ordinary value (BLOCK-DISCARD), but it does not discharge a Result. Handle the failure with match or ?:, or return it to a caller who will.