Why Explicit Error Handling?
Simplex has no null values and no exceptions. Instead, errors are
explicit values that must be handled. This approach:
- Makes failure cases visible in function signatures
- Prevents null pointer exceptions
- Forces you to consider what can go wrong
- Makes code more reliable and predictable
The Result Type
Result<T, E> represents an operation that might succeed with
a value of type T or fail with an error of type E:
// Result is a built-in enum
enum Result<T, E> {
Ok(T), // Success with value
Err(E) // Failure with error
}
// Return Result from functions that can fail
fn divide(a: i64, b: i64) -> Result<i64, String> {
if b == 0 {
return Err("Division by zero")
}
Ok(a / b)
}
fn main() {
let result = divide(10, 2)
// Check if operation succeeded
print(result.is_ok()) // true
print(result.is_err()) // false
// Get the success value
let value: Result<i64, String> = Ok(42)
let error: Result<i64, String> = Err("Something went wrong")
}
The Option Type
Option<T> represents a value that might or might not exist.
It replaces null with explicit absence:
// Option is a built-in enum
enum Option<T> {
Some(T), // There's a value
None // There's no value
}
// Use Option when a value might not exist
fn find_user(id: String) -> Option<User> {
let users = get_all_users()
for user in users {
if user.id == id {
return Some(user)
}
}
None
}
fn main() {
let present: Option<i64> = Some(42)
let absent: Option<i64> = None
// Check if value exists
print(present.is_some()) // true
print(present.is_none()) // false
print(absent.is_none()) // true
}
The ? Operator
The ? operator makes error propagation concise. It unwraps
Ok/Some values or returns early on Err/None:
// Without ? operator - verbose
fn process_order_verbose(id: String) -> Result<Receipt, OrderError> {
let order = match find_order(id) {
Ok(o) => o,
Err(e) => return Err(e)
}
let payment = match process_payment(order) {
Ok(p) => p,
Err(e) => return Err(e)
}
Ok(Receipt { order, payment })
}
// With ? operator - clean and readable
fn process_order(id: String) -> Result<Receipt, OrderError> {
let order = find_order(id)? // Returns Err early if fails
let payment = process_payment(order)? // Returns Err early if fails
Ok(Receipt { order, payment })
}
// ? also works with Option
fn get_username(user_id: String) -> Option<String> {
let user = find_user(user_id)? // Returns None if not found
let profile = get_profile(user)? // Returns None if no profile
Some(profile.username)
}
How ? Works
The ? operator on Ok(value) unwraps to value.
On Err(e), it immediately returns Err(e) from the function.
This makes chaining fallible operations natural and readable.
Pattern Matching on Result/Option
Use match to handle all cases explicitly:
fn handle_result(result: Result<i64, ParseError>) {
match result {
Ok(value) => print("Success: {value}"),
Err(ParseError::InvalidFormat) => print("Invalid format"),
Err(ParseError::NumberTooLarge) => print("Number too large"),
Err(ParseError::EmptyInput) => print("Empty input")
}
}
fn handle_option(opt: Option<User>) {
match opt {
Some(user) => print("Found user: {user.name}"),
None => print("User not found")
}
}
// If-let for single case matching
fn greet_if_found(user_id: String) {
if let Some(user) = find_user(user_id) {
print("Hello, {user.name}!")
}
}
// Match with guards
fn check_value(result: Result<i64, Error>) -> String {
match result {
Ok(n) if n > 100 => "Large success",
Ok(n) if n > 0 => "Small success",
Ok(_) => "Zero or negative",
Err(e) => "Error: {e}"
}
}
Custom Error Types
Define meaningful error types for your domain using enums:
// Simple error enum
enum ParseError {
InvalidFormat,
NumberTooLarge,
EmptyInput
}
// Error with associated data
enum AppError {
NotFound(String), // What wasn't found
Unauthorized,
ValidationFailed(List<String>), // List of validation messages
Database(DatabaseError), // Wrapped error
Internal(String) // Error message
}
// Add methods to error types
impl AppError {
fn is_retryable(this: AppError) -> Bool {
match this {
AppError::Database(_) => true,
AppError::Internal(_) => true,
_ => false
}
}
fn message(this: AppError) -> String {
match this {
AppError::NotFound(item) => "Not found: {item}",
AppError::Unauthorized => "Unauthorized access",
AppError::ValidationFailed(errors) => "Validation failed: {errors}",
AppError::Database(e) => "Database error: {e}",
AppError::Internal(msg) => "Internal error: {msg}"
}
}
}
// Use custom errors in functions
fn get_user(id: String) -> Result<User, AppError> {
let user = db::find_user(id)
.ok_or(AppError::NotFound("User {id}"))?
Ok(user)
}
Error Transformation with map_err
Use map_err to convert between error types when combining
different operations:
enum FetchError {
Http(HttpError),
Parse(ParseError),
Timeout
}
fn fetch_and_parse(url: String) -> Result<Data, FetchError> {
// Convert HttpError to FetchError
let response = http::get(url)
.map_err(e => FetchError::Http(e))?
// Convert ParseError to FetchError
let data = parse_json(response.body)
.map_err(e => FetchError::Parse(e))?
Ok(data)
}
// Chain multiple transformations
fn load_config() -> Result<Config, AppError> {
let contents = read_file("config.toml")
.map_err(e => AppError::Internal("Failed to read config: {e}"))?
let parsed = parse_toml(contents)
.map_err(e => AppError::ValidationFailed([e.to_string()]))?
let config = validate_config(parsed)
.map_err(e => AppError::ValidationFailed(e.errors))?
Ok(config)
}
unwrap_or and unwrap_or_else
Provide default values when unwrapping Option or Result:
fn main() {
let some_value: Option<i64> = Some(42)
let no_value: Option<i64> = None
// unwrap_or: use a constant default
print(some_value.unwrap_or(0)) // 42
print(no_value.unwrap_or(0)) // 0
// unwrap_or_else: compute default lazily
let value = no_value.unwrap_or_else(|| {
print("Computing default...")
expensive_computation()
})
// Works with Result too
let result: Result<i64, Error> = Err(Error::new("failed"))
let value = result.unwrap_or(-1) // -1
}
// Practical example: settings with defaults
fn get_setting(name: String) -> String {
read_config(name).unwrap_or_else(|| {
match name {
"timeout" => "30",
"retries" => "3",
"host" => "localhost",
_ => ""
}
})
}
// map: transform the inner value
fn get_user_email(id: String) -> Option<String> {
find_user(id).map(user => user.email)
}
Creating Error Chains
Preserve context when errors propagate through multiple layers:
// Error type that can wrap other errors
enum ServiceError {
Database { cause: DatabaseError, context: String },
Network { cause: NetworkError, context: String },
Validation { messages: List<String> },
Internal { message: String, source: Option<String> }
}
impl ServiceError {
// Wrap a database error with context
fn from_db(error: DatabaseError, context: String) -> ServiceError {
ServiceError::Database { cause: error, context }
}
// Get the full error chain as string
fn chain(this: ServiceError) -> String {
match this {
ServiceError::Database { cause, context } =>
"{context}: {cause}",
ServiceError::Network { cause, context } =>
"{context}: {cause}",
ServiceError::Validation { messages } =>
"Validation errors: {messages.join(\", \")}",
ServiceError::Internal { message, source } =>
match source {
Some(s) => "{message}: {s}",
None => message
}
}
}
}
// Build error context as it propagates
fn create_order(user_id: String, items: List<Item>) -> Result<Order, ServiceError> {
let user = db::find_user(user_id)
.map_err(e => ServiceError::from_db(e, "Loading user {user_id}"))?
let inventory = db::check_inventory(items)
.map_err(e => ServiceError::from_db(e, "Checking inventory"))?
let order = db::create_order(user, items)
.map_err(e => ServiceError::from_db(e, "Creating order for user {user_id}"))?
Ok(order)
}
// Handle errors with full context
fn handle_request(request: Request) {
match create_order(request.user_id, request.items) {
Ok(order) => respond_success(order),
Err(e) => {
log::error("Order failed: {e.chain()}")
respond_error(e)
}
}
}
Try It Yourself
Build a user validation system that:
- Validates name (non-empty), email (contains @), and age (positive)
- Collects all validation errors, not just the first one
- Returns
Result<User, List<ValidationError>> - Includes helpful error messages for each field
Summary
In this tutorial, you learned:
Result<T, E>for operations that can fail withOkandErrOption<T>for values that might be absent withSomeandNone- The
?operator for concise error propagation - Pattern matching with
matchfor exhaustive error handling - Creating custom error types with enums
- Using
map_errto transform error types unwrap_orandunwrap_or_elsefor default values- Building error chains to preserve context
In the next tutorial, we'll explore Anima and Memory for building stateful AI agents with persistent context.