Error Handling

Errors happen. Files don't exist, network requests fail, parsing goes wrong. The question is: how do we handle them elegantly, safely, and without excessive ceremony?

Coco's error handling merges the best ideas from Go and Zig: errors are values that auto-propagate unless explicitly handled.

The Core Model

Functions that can fail return an error union type using the ! prefix:

fn read_file($path string): !string {
    if !file_exists($path) {
        return error("File not found: {$path}");
    }
    // ... read and return content
    return $content;
}

The return type !string means "returns a string, or an error."

What Makes This Different

Unlike Go: You don't have to manually check every error. Errors auto-propagate by default.

Unlike Rust: You don't need Result<T, E> enums or ? operators. The ! syntax is simpler.

Unlike Zig: You don't need try for every call. Auto-propagation is the default.

The Coco way: Errors propagate automatically unless you explicitly handle them.

Creating Errors

The error type is a built-in primitive:

// Simple error message
return error("Something went wrong");

// Formatted error
return error("Failed to read {$path}: {$reason}");

// Error with context
return error.wrap($inner_error, "While processing user data");

Error Structure

// The error type (built-in)
struct error {
    message string,
    stack_trace Option<StackTrace>,
    source Option<&error>  // Wrapped error
}

All errors carry:

  • A message (required)
  • Stack trace (captured automatically)
  • Source error (if wrapped)

Error Union Types

The !T syntax is sugar for "T or error":

!string    // string or error
!i32       // i32 or error
!Vec<User> // Vec<User> or error
!null      // void or error (just signals success/failure)

Under the hood, this is an enum:

enum ErrorUnion<T> {
    Ok(T),
    Err(error)
}

But you never write this explicitly. The ! syntax handles it for you.

Auto-Propagation

This is where Coco shines: if you don't handle an error, it propagates automatically.

fn process_user($id i32): !User {
    // read_config returns !Config
    // If it errors, the error propagates automatically
    $config = read_config("app.conf");

    // fetch_user returns !User
    // Again, errors propagate automatically
    $user = fetch_user($id);

    return $user;
}

No ?, no try, no if err != nil. Just write the happy path.

Propagation Rules

  1. Only works in functions that return !T: You can't auto-propagate in functions that don't return an error union.
  1. Type compatibility: The error propagates up the call stack until caught or returned.
  1. Compile-time checking: The compiler ensures you don't accidentally ignore errors.
// This function CANNOT auto-propagate (doesn't return !T)
fn main() {
    // ERROR: can't auto-propagate here
    $config = read_config("app.conf");  // Compile error!

    // Must handle explicitly
    $config = read_config("app.conf") catch $err {
        print("Failed to read config: {$err}");
        return;
    };
}

Explicit Error Handling

When you want to handle an error, you have several options:

Option 1: catch Block

Handle the error inline with a catch block:

$content = read_file("data.txt") catch $err {
    echo "Error reading file: {$err}";
    return "default content";  // Provide fallback value
};

// $content is now a string (not !string)
echo $content;

The catch block must return a value of the same type as the success case.

Option 2: Go-Style Multiple Returns

Explicitly unpack the error for manual checking:

$content, $err = read_file("data.txt");
if $err != null {
    echo "Error: {$err}";
    return;
}

// $content is valid here, $err was null
echo $content;

This gives you Go's explicit control when you want it.

Option 3: Unwrap (Panic on Error)

If you're certain a call won't fail, use unwrap():

$content = read_file("config.txt").unwrap();  // Panics if error

Use sparingly! Only when you're absolutely sure there won't be an error.

Option 4: Unwrap with Custom Panic Message

$content = read_file("config.txt").expect("Config file must exist");

Panics with a custom message if an error occurs.

Examples

Simple Error Handling

fn divide($a f64, $b f64): !f64 {
    if $b == 0.0 {
        return error("Division by zero");
    }
    return $a / $b;
}

fn calculate(): !f64 {
    $result = divide(10.0, 2.0);  // Auto-propagates if error
    return $result * 2.0;
}

fn main() {
    $value = calculate() catch $err {
        echo "Calculation failed: {$err}";
        return;
    };

    echo "Result: {$value}";
}

File Processing

fn process_config($path string): !Config {
    // All these can error, all auto-propagate
    $content = read_file($path);
    $parsed = parse_json($content);
    $validated = validate_config($parsed);

    return $validated;
}

fn main() {
    // Handle at the top level
    $config = process_config("app.conf") catch $err {
        echo "Failed to load config: {$err}";
        echo "Using defaults";
        return Config::default();
    };

    run_app($config);
}

Wrapping Errors for Context

fn load_user_data($id i32): !UserData {
    $file_path = "/data/users/{}.json", $id;

    $content = read_file($file_path) catch $err {
        return error.wrap($err, "Failed to load user {$id}");
    };

    $data = parse_json($content) catch $err {
        return error.wrap($err, "Invalid JSON for user {$id}");
    };

    return $data;
}

Fallback Values

fn get_cached_or_fetch($key string): !Data {
    // Try cache first, fall back to network
    $cached = cache.get($key) catch {
        // Ignore cache errors, fetch from network
        return fetch_from_network($key);
    };

    return $cached;
}

Error Propagation in Detail

The Happy Path

Most code just works:

fn fetch_and_process(): !Result {
    $data = fetch_data();        // Auto-propagates
    $parsed = parse($data);      // Auto-propagates
    $validated = validate($parsed);  // Auto-propagates
    $processed = process($validated);  // Auto-propagates
    return $processed;
}

Clean, readable, no boilerplate.

When Errors Need Handling

Handle errors only where it makes sense:

fn fetch_with_retry($url string): !Response {
    // Try 3 times
    for $i in 0..3 {
        $response = http_get($url) catch $err {
            if $i == 2 {
                // Last attempt failed
                return error.wrap($err, "Failed after 3 attempts");
            }
            // Try again
            continue;
        };

        return $response;
    }

    // Unreachable, but compiler requires it
    return error("Unreachable");
}

Comparison to Other Languages

Go

Go's approach:

content, err := readFile("data.txt")
if err != nil {
    return err
}
// use content

Coco's approach:

$content = read_file("data.txt");  // Auto-propagates
// use content

Benefit: No if err != nil boilerplate everywhere. But you can still use Go-style when you want it.

Rust

Rust's approach:

let content = read_file("data.txt")?;

Coco's approach:

$content = read_file("data.txt");

Benefit: No ? operator needed. Propagation is automatic.

Zig

Zig's approach:

const content = try readFile("data.txt");

Coco's approach:

$content = read_file("data.txt");

Benefit: No try keyword needed. Propagation is automatic.

Error Handling Best Practices

1. Let Errors Propagate

Don't catch errors just to re-throw them:

// Bad: Catching just to re-throw
fn process(): !Data {
    $result = fetch_data() catch $err {
        return $err;  // Why catch at all?
    };
    return $result;
}

// Good: Just let it propagate
fn process(): !Data {
    $result = fetch_data();  // Automatically propagates
    return $result;
}

2. Handle Errors at Meaningful Boundaries

Catch errors where you can actually do something about them:

// Good: Handle at application boundary
fn main() {
    $app = App::new() catch $err {
        echo "Failed to initialize: {$err}";
        exit(1);
    };

    $app.run();
}

3. Add Context When Wrapping

Make errors informative:

// Bad: Lost context
fn load_config(): !Config {
    return parse_file("config.json");
}

// Good: Added context
fn load_config(): !Config {
    $config = parse_file("config.json") catch $err {
        return error.wrap($err, "Failed to load application config");
    };
    return $config;
}

4. Use Meaningful Error Messages

// Bad: Vague
return error("Invalid input");

// Good: Specific
return error("Username must be 3-20 characters, got {$username.len()}");

5. Don't Ignore Errors in Production

// Bad: Silent failure
$data = fetch_data() catch {
    return Data::default();  // Error silently ignored
};

// Good: Log the error
$data = fetch_data() catch $err {
    echo "Warning: Failed to fetch data, using default: {$err}";
    return Data::default();
};

Panics vs Errors

Coco distinguishes between recoverable errors and unrecoverable panics:

Use Errors For

  • Expected failures (file not found, network timeout)
  • Validation failures
  • Recoverable problems
  • Situations where the caller should decide what to do

Use Panics For

  • Programming bugs (index out of bounds, null pointer)
  • Invariant violations
  • Unrecoverable situations
  • Situations that should never happen
// Error: Expected failure
fn read_file($path string): !string {
    if !file_exists($path) {
        return error("File not found: {$path}");
    }
    // ...
}

// Panic: Programming bug
fn get_element<T>($arr [T], $idx usize): T {
    if $idx >= $arr.len() {
        panic("Index out of bounds: {$idx} >= {$arr.len()}");
    }
    return $arr[$idx];
}

The Error Type

The built-in error type provides useful methods:

// Create an error
$err = error("Something failed");

// Get the message
$msg = $err.message();  // "Something failed"

// Get the stack trace
$trace = $err.stack_trace();

// Wrap an error with context
$wrapped = error.wrap($err, "While processing request");

// Get the source error
$source = $wrapped.source();  // Some($err)

Custom Error Data

For domain-specific error information, use enums:

enum DatabaseError {
    ConnectionFailed(string),
    QueryTimeout(Duration),
    InvalidQuery(string)
}

fn execute_query($sql string): !QueryResult {
    // Convert domain error to general error
    $result = db.execute($sql) catch $db_err {
        match $db_err {
            DatabaseError::ConnectionFailed($msg) => {
                return error("Database connection failed: {$msg}");
            },
            DatabaseError::QueryTimeout($duration) => {
                return error("Query timed out after {$duration.as_millis()}ms");
            },
            DatabaseError::InvalidQuery($msg) => {
                return error("Invalid SQL: {$msg}");
            }
        }
    };

    return $result;
}

The Result

Coco's error handling gives you:

  1. Simplicity: !T is easier than Result<T, E>
  2. Safety: Can't ignore errors—they propagate automatically
  3. Flexibility: Choose explicit handling when you need it
  4. Ergonomics: No boilerplate for the happy path
  5. Transparency: Clear in function signatures which functions can fail

Errors are values. They propagate by default. Handle them where it makes sense.

Simple, safe, practical.

Copyright (c) 2025 Ocean Softworks, Sharkk