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 incore.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(...)andoption.unwrap(...)are called through the module alias, matching theimport core.optionabove (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]andResult[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 (aprocon 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:
?operator - Early return on error:let x = fallible()?.map()and.and_then()- Functional combinators overOption/Result- Type constraints on generics (
T: Comparable) - see 055_GENERICS.md - Custom
Error-like types with conversion between error types across module boundaries
Previous: 030_TYPES.md Next: 040_OBJECTS.md