Simplex has moved to commercial development. The open-source lineage (v0.17, AGPL-3.0) is archived on GitHub; these pages document the archived release. Commercial enquiries

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.