Error Handling

Part of: Reef Language Reference Last reviewed on version: 0.8.0 Status: Best Practices Guide


Overview

Reef uses value-based error handling with Option and Result types instead of exceptions. This approach:

  • Makes error paths explicit in function signatures
  • Forces callers to handle errors (compiler-enforced)
  • Avoids hidden control flow jumps
  • Integrates naturally with pattern matching

Both Option[T] and Result[T, E] are generic types — one type definition each, parameterized over the value(s) they carry, rather than a family of per-type monomorphized variants. Type arguments are inferred from context in most cases, so callers rarely need to write them out.


Core Types

Option[T] - Representing Absence

Use Option[T] when a value might not exist. It is declared in core.option as:

type Option[T] = enum
    Some(T)
    None
end Option
import core.option

// Looking up a value that might not exist
fn find_user(id: int): Option[string]
    if id == 1
        return Option_Some("Alice")
    end if
    return @Option[string].None()
end find_user

proc main()
    let user = find_user(1)

    if option.is_some(user)
        let name = option.unwrap(user)
        println("Found: ${name}")
    else
        println("User not found")
    end if
end main

Notes on the syntax above:

  • Option_Some(x) is a bare global constructor — no module prefix, even though the type lives in core.option. Type arguments are inferred from the argument.
  • @Option[string].None() (or the fully module-qualified @option.Option[string].None()) is the explicit form used when there's no value to infer the type argument from.
  • option.is_some(...) and option.unwrap(...) are called through the module alias, matching the import core.option above (the module's short name, option, is the implicit alias).

Result[T, E] - Representing Success or Failure

Use Result[T, E] when an operation can fail and you want to communicate why. It is declared in core.result as:

type Result[T, E] = enum
    Ok(T)
    Err(E)
end Result

Standard library code pairs the error type E with core.error.Error (see below), giving Result[T, error.Error]:

import core.result
import core.error as error

fn parse_port(s: string): Result[int, error.Error]
    if s == "8080"
        return @Result[int, error.Error].Ok(8080)
    end if
    return @Result[int, error.Error].Err(error.error(ErrorKind_InvalidInput(), "invalid port number"))
end parse_port

proc main()
    let r = parse_port("8080")

    if result.is_ok(r)
        let port = result.unwrap_ok(r)
        println("Port: ${port}")
    else
        let err = result.unwrap_err(r)
        println("Error: ${error.error_message(err)}")
    end if
end main

Because each Result variant only carries one of the two type parameters, construction needs an explicit type argument list: @Result[T, E].Ok(x) / @Result[T, E].Err(e). The query and unwrap functions (is_ok, unwrap_ok, etc.) infer their type arguments from the Result value passed in, so they don't need annotation.

Error - A Structured Error Value

core.error.Error is the standard error payload used across the stdlib as Result's E:

type Error = struct
    kind: ErrorKind
    message: string
    code: int
end Error

type ErrorKind = enum
    NotFound
    InvalidInput
    PermissionDenied
    IoError
    AlreadyExists
    Unsupported
    WouldBlock
    Timeout
    OutOfRange
    Other
end ErrorKind

Construct one with error.error(kind, message) (code defaults to 0) or error.error_with_code(kind, message, code). Read it back with error.error_kind(e), error.error_message(e), error.error_code(e).

ErrorKind is a plain (non-generic) enum, so its variant constructors follow the unqualified ErrorKind_Variant() idiom — they are bare globals, not prefixed by whatever alias you imported core.error under:

import core.error as error

let e = error.error(ErrorKind_NotFound(), "missing key")   // ErrorKind_NotFound(), not error.ErrorKind_NotFound()

error_kind is for programmatic branching — deciding what to do based on the failure category:

fn describe(e: error.Error): string
    match error.error_kind(e)
        ErrorKind_NotFound() =>
            return "not found"
        end
        ErrorKind_InvalidInput() =>
            return "invalid input"
        end
        _ =>
            return "other"
        end
    end match
end describe

For simply displaying an error, don't switch on the kind — just print the message:

println("Error: ${error.error_message(e)}")

Common Patterns

Pattern 1: Check and Unwrap

The most explicit pattern - check first, then unwrap:

import core.result
import core.error as error

fn read_config(): Result[string, error.Error]
    // ... read from file
    return @Result[string, error.Error].Ok("value")
end read_config

proc main()
    let outcome = read_config()

    if result.is_ok(outcome)
        let config = result.unwrap_ok(outcome)
        // Use config...
    else
        let err = result.unwrap_err(outcome)
        println("Failed to read config: ${error.error_message(err)}")
        return
    end if
end main

Pattern 2: Provide Defaults

When you have a sensible fallback value, skip the branch entirely with unwrap_or:

import core.option

fn get_timeout(): Option[int]
    // Maybe returns a configured timeout
    return @Option[int].None()
end get_timeout

proc main()
    // Use 30 seconds as default if not configured
    let timeout = option.unwrap_or(get_timeout(), 30)
    println("Timeout: ${timeout}")
end main

result.unwrap_or(r, default) works the same way for Result[T, E], returning default on Err instead of panicking.

Pattern 3: Pattern Matching

For more complex handling, use match:

import core.result
import core.error as error

fn divide(a: int, b: int): Result[int, error.Error]
    if b == 0
        return @Result[int, error.Error].Err(error.error(ErrorKind_InvalidInput(), "division by zero"))
    end if
    return @Result[int, error.Error].Ok(a / b)
end divide

proc main()
    let outcome = divide(10, 2)

    match outcome
        Result_Ok(value) =>
            println("Result: ${value}")
        end
        Result_Err(e) =>
            println("Error: ${error.error_message(e)}")
        end
    end match
end main

Pattern 4: Early Return on Error

For functions that should propagate errors, check with is_err and return the Result value itself — no need to unwrap and rewrap it:

import core.result
import core.error as error
import core.convert as convert

fn parse_port(s: string): Result[int, error.Error]
    let parsed = convert.toInt(s)
    if result.is_err(parsed)
        return parsed   // Propagate error as-is
    end if

    let port = result.unwrap_ok(parsed)
    if port < 1 or port > 65535
        return @Result[int, error.Error].Err(error.error(ErrorKind_OutOfRange(), "port must be 1-65535"))
    end if
    return @Result[int, error.Error].Ok(port)
end parse_port

This only works when the propagating function's error type matches the callee's (error.Error on both sides above). If the types differ, unwrap the error and re-wrap it in the caller's own Error.

Pattern 5: Combine with defer for Cleanup

Use defer to ensure cleanup happens regardless of error path:

fn process_file(path: string): Result[string, error.Error]
    let handle = open_file(path)
    if result.is_err(handle)
        return @Result[string, error.Error].Err(error.error(ErrorKind_IoError(), "could not open file"))
    end if
    let fh = result.unwrap_ok(handle)

    defer
        close_file(fh)
    end defer

    // Process file...
    // defer runs even if we return early

    return @Result[string, error.Error].Ok("success")
end process_file

Constructing Values

Creating Option Values

// Some value exists - type inferred from the argument
let present = Option_Some(42)

// No value - type argument required, nothing to infer from
let absent = @Option[int].None()

Creating Result Values

// Success
let success = @Result[int, error.Error].Ok(100)

// Failure with a structured error
let failure = @Result[int, error.Error].Err(error.error(ErrorKind_Other(), "something went wrong"))

Helper Functions Reference

Option[T] Helpers (core.option)

// Check if value exists
fn is_some[T](opt: Option[T]): bool
fn is_none[T](opt: Option[T]): bool

// Extract value - PANICS if opt is None. Only call after is_some, or prefer unwrap_or.
fn unwrap[T](opt: Option[T]): T

// Extract with default fallback - never panics
fn unwrap_or[T](opt: Option[T], default: T): T

Result[T, E] Helpers (core.result)

// Check success/failure
fn is_ok[T, E](r: Result[T, E]): bool
fn is_err[T, E](r: Result[T, E]): bool

// Extract success value - PANICS if r is Err. Only call after is_ok.
fn unwrap_ok[T, E](r: Result[T, E]): T

// Extract error value - PANICS if r is Ok. Only call after is_err.
fn unwrap_err[T, E](r: Result[T, E]): E

// Extract success value with default fallback - never panics
fn unwrap_or[T, E](r: Result[T, E], default: T): T

Error Helpers (core.error)

fn error(kind: ErrorKind, message: string): Error
fn error_with_code(kind: ErrorKind, message: string, code: int): Error
fn error_kind(e: Error): ErrorKind
fn error_message(e: Error): string
fn error_code(e: Error): int

Note on generics scope: Option[T] and Result[T, E] are ordinary generic types with generic free functions (is_some[T], unwrap_ok[T, E], ...) operating on them — Reef does not support method-level generic type parameters (a proc on a struct/AO introducing its own type parameter). See 055_GENERICS.md for details and the recommended "generic free function with an explicit receiver" idiom.


Design Guidelines

When to Use Option

Use Option[T] when:

  • A value might not exist (lookup, find, search)
  • Absence is not an error, just a possibility
  • No error message is needed
// Good: an environment variable might not be set
fn get_env(name: string): option.Option[string]

// Good: first element might not exist
fn first(arr: [int]): Option[int]

sys.env.get_env(name: string): option.Option[string] is a real stdlib example: it returns Some(value) if the variable is set, None if it's unset — there's no "reason" to report, so Option is the right fit.

When to Use Result

Use Result[T, error.Error] when:

  • An operation can fail
  • You want to communicate why it failed
  • Callers need to know what went wrong
// Good: parsing can fail with an explanation
fn toInt(s: string): result.Result[int, error.Error]

// Good: validation with a specific error
fn validate_email(email: string): Result[bool, error.Error]

core.convert.toInt(s) and core.str.to_int(s) are real stdlib examples, both Result[int, error.Error] — the same signature shape you should use for your own fallible parsing/validation functions.

Error Message Guidelines

Write clear, actionable error messages:

// Bad: vague
error.error(ErrorKind_Other(), "error")

// Bad: technical jargon with no context
error.error(ErrorKind_InvalidInput(), "EINVAL")

// Good: explains what happened
error.error(ErrorKind_OutOfRange(), "port number must be between 1 and 65535")

// Good: suggests what to do
error.error(ErrorKind_NotFound(), "config.toml not found - create it or specify --config")

Pick the ErrorKind that best matches the failure category — it lets callers branch programmatically (e.g. retry on WouldBlock, prompt the user again on InvalidInput) without parsing the message string.


Comparison with Other Languages

vs Exceptions (Java, Python, C++)

Exceptions Reef Result
Hidden control flow Explicit in types
Can forget to catch Must handle (type-checked)
Stack unwinding overhead Zero-cost when Ok
Catch-all hides bugs Each error path explicit

vs Go Error Returns

Go (T, error) Reef Result[T, E]
Can ignore error Must pattern match or check is_err
Check err != nil Use result.is_ok(...)
Error is separate value Single sum type

vs Rust Result

Rust Reef
Result<T, E> generic Result[T, E] generic (same shape)
? operator Manual early return (see Pattern 4)
.map(), .and_then() Not yet in stdlib - manual match/if

Common Mistakes

Mistake 1: Unwrapping Without Checking

unwrap and unwrap_ok/unwrap_err panic (abort the program) when called on the wrong variant — they are not safe-by-default:

// WRONG: panics if maybe_value is None
let value = option.unwrap(maybe_value)

// RIGHT: check first
if option.is_some(maybe_value)
    let value = option.unwrap(maybe_value)
end if

// RIGHT: or use a default, which never panics
let value = option.unwrap_or(maybe_value, 0)

The same applies to Result: result.unwrap_ok(r) panics on Err, result.unwrap_err(r) panics on Ok. Guard with is_ok/is_err, use unwrap_or, or use match to handle both arms explicitly.

Mistake 2: Ignoring the Result

// WRONG: ignoring possible failure
save_file(data)

// RIGHT: handle the result
let outcome = save_file(data)
if result.is_err(outcome)
    let err = result.unwrap_err(outcome)
    println("Save failed: ${error.error_message(err)}")
end if

Mistake 3: Using Option for Errors

// WRONG: no error information - why did it fail?
fn validate(x: int): Option[bool]
    if x < 0
        return @Option[bool].None()
    end if
    return Option_Some(true)
end validate

// RIGHT: include an error reason
fn validate(x: int): Result[bool, error.Error]
    if x < 0
        return @Result[bool, error.Error].Err(error.error(ErrorKind_InvalidInput(), "value must be non-negative"))
    end if
    return @Result[bool, error.Error].Ok(true)
end validate

Mistake 4: Nesting Unwrap Calls Inside String Interpolation

Prefer binding the unwrapped value to a let before using it in ${...} interpolation, rather than calling unwrap/unwrap_ok/unwrap_err directly inside the interpolated expression:

// AVOID: unwrap call nested inside interpolation
println("Error: ${error.error_message(result.unwrap_err(outcome))}")

// PREFER: bind first, then interpolate
let err = result.unwrap_err(outcome)
println("Error: ${error.error_message(err)}")

Besides being easier to read, this sidesteps codegen edge cases around nested generic calls inside interpolated arguments — it's a good habit generally, not just for error handling.


Real-World Example

A complete example showing error handling in practice:

import core.result
import core.option
import core.error as error
import core.convert as convert

// Configuration with validation
type Config = struct
    host: string
    port: int
end Config

fn parse_port(s: string): Result[int, error.Error]
    let parsed = convert.toInt(s)
    if result.is_err(parsed)
        return parsed
    end if
    let port = result.unwrap_ok(parsed)
    if port < 1 or port > 65535
        return @Result[int, error.Error].Err(error.error(ErrorKind_OutOfRange(), "port must be 1-65535"))
    end if
    return @Result[int, error.Error].Ok(port)
end parse_port

fn create_config(host: string, port_str: string): Result[Config, error.Error]
    let port_result = parse_port(port_str)
    if result.is_err(port_result)
        return @Result[Config, error.Error].Err(result.unwrap_err(port_result))
    end if
    let port = result.unwrap_ok(port_result)

    let cfg: Config = new Config()
    cfg.host = host
    cfg.port = port
    return @Result[Config, error.Error].Ok(cfg)
end create_config

proc main()
    let outcome = create_config("localhost", "8080")

    match outcome
        Result_Ok(cfg) =>
            println("Config created: ${cfg.host}:${cfg.port}")
        end
        Result_Err(e) =>
            let msg = error.error_message(e)
            println("Configuration failed: ${msg}")
        end
    end match
end main

Program Termination and Exit Codes

For fatal errors where recovery is not possible, Reef provides mechanisms to terminate the program with an exit code.

The exit() Function

The exit(code) function is a built-in that immediately terminates the program with the specified exit code:

proc main()
    let config = load_config()
    if not config_valid(config)
        println("Fatal: Invalid configuration")
        exit(1)  // Exit immediately with error code 1
    end if

    // Continue with valid config...
end main

Key points:

  • exit(code) can be called from anywhere (main, nested functions, procedures)
  • Performs cleanup (GC, objects) before terminating
  • Exit code 0 typically means success, non-zero means error
  • Does not return - code after exit() is unreachable

Exception — forked children. That cleanup is exactly why exit() is the wrong call in a process created by process.process_fork(): it waits on threads that did not survive the fork, and the child hangs. Use process.exit_now(code) there, which skips cleanup. See 115_SYSTEMS_PROGRAMMING.md.

The panic() Function

panic(message) is what unwrap/unwrap_ok/unwrap_err call internally when handed the wrong variant. You can call it directly for your own "this should never happen" invariants:

if not is_valid_state()
    panic("unreachable: invariant violated in state machine")
end if

panic prints the message and aborts the process (non-zero exit) — it does not return, and unlike Result/Option it cannot be recovered from. Reserve it for programmer errors and invariant violations, not for expected failure conditions (use Result for those).

Using fn main(): int

For programs that need to report exit status, use fn main(): int instead of proc main():

import core.result
import core.error as error

fn run_app(): Result[int, error.Error]
    // ... application logic
    return @Result[int, error.Error].Ok(0)
end run_app

fn main(): int
    let outcome = run_app()

    if result.is_err(outcome)
        let err = result.unwrap_err(outcome)
        println("Error: ${error.error_message(err)}")
        return 1
    end if

    return 0  // Success
end main

Comparison:

Entry Point Exit Code
proc main() Always 0
fn main(): int Return value
exit(code) from anywhere Specified code
panic(message) from anywhere Non-zero (abort)

When to Use Each Approach

Scenario Recommendation
Simple programs, no error states proc main()
CLI tools needing exit codes fn main(): int
Fatal errors in nested code exit(code)
Programmer errors / invariant violations panic(message)
Recoverable errors Result[T, error.Error]

Example: CLI Tool with Exit Codes

import core.result
import core.error as error

fn validate_args(): bool
    if reef_args_count() < 2
        println("Usage: program <input-file>")
        exit(2)  // Exit with usage error
    end if
    return true
end validate_args

fn process_file(path: string): Result[string, error.Error]
    // ... file processing
    return @Result[string, error.Error].Ok("processed")
end process_file

fn main(): int
    validate_args()

    let path = reef_args_get(1)
    let outcome = process_file(path)

    if result.is_err(outcome)
        let err = result.unwrap_err(outcome)
        println("Error: ${error.error_message(err)}")
        return 1
    end if

    let msg = result.unwrap_ok(outcome)
    println("Success: ${msg}")
    return 0
end main

Future Improvements

Option[T] and Result[T, E] are already generic today, and panic() already exists — those are no longer future work. The following are still planned for future Reef versions:

  1. ? operator - Early return on error: let x = fallible()?
  2. .map() and .and_then() - Functional combinators over Option/Result
  3. Type constraints on generics (T: Comparable) - see 055_GENERICS.md
  4. Custom Error-like types with conversion between error types across module boundaries

Previous: 030_TYPES.md Next: 040_OBJECTS.md