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-basics.sx
// 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-basics.sx
// 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:

propagation.sx
// 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:

matching.sx
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:

custom-errors.sx
// 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:

map-err.sx
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:

unwrap-defaults.sx
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-chains.sx
// 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:

  1. Validates name (non-empty), email (contains @), and age (positive)
  2. Collects all validation errors, not just the first one
  3. Returns Result<User, List<ValidationError>>
  4. Includes helpful error messages for each field

Summary

In this tutorial, you learned:

  • Result<T, E> for operations that can fail with Ok and Err
  • Option<T> for values that might be absent with Some and None
  • The ? operator for concise error propagation
  • Pattern matching with match for exhaustive error handling
  • Creating custom error types with enums
  • Using map_err to transform error types
  • unwrap_or and unwrap_or_else for 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.