Error Returns

Errors happen—files don't exist, networks fail, parsing goes wrong. Coco's error handling is designed to be explicit, safe, and not annoying.

Error Unions

Functions that can fail return an error union type, written as !T:

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

The !string means "returns a string or an error." You can't ignore it.

Creating Errors

Use the error() function to create errors:

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

// Formatted message
return error("Invalid value: {$value}");

// Wrap an existing error with context
return error.wrap($err, "Failed to process file");

Handling Errors

You have several options for handling error returns.

Auto-Propagation

If you don't handle an error, it propagates automatically:

fn process_user($id i32): !User {
    $config = read_config("app.conf");  // If this fails...
    $user = fetch_user($id);             // ...error propagates up
    return $user;
}

No try, no ?, no boilerplate. If read_config returns an error, process_user returns that error immediately.

The catch Keyword

Handle errors inline with catch:

$content = read_file($path) catch $err {
    echo "Error: {$err}";
    return "";
};

The catch block receives the error and must either:

  • Return a value of the expected type
  • Return an error
  • Use continue, break, or return

Match on Result

For more control, match on the result:

match read_file($path) {
    Ok($content) => process($content),
    Err($e) => {
        log_error($e);
        use_default()
    }
}

Provide a Default

Use or for simple defaults:

$content = read_file($path) or "";
$config = load_config() or Config::default();

Chaining Errors

Add context as errors bubble up:

fn process_order($id i32): !Order {
    $data = fetch_order_data($id) catch $err {
        return error.wrap($err, "Failed to fetch order {$id}");
    };

    $parsed = parse_order($data) catch $err {
        return error.wrap($err, "Failed to parse order {$id}");
    };

    return $parsed;
}

When this fails, you get a chain: "Failed to process order 42: Failed to parse order 42: Invalid JSON at line 15"

Multiple Return Values (Go-style)

For compatibility or when you prefer explicit handling:

fn read_file_go($path string): (string, ?Error) {
    if !file_exists($path) {
        return ("", Error::new("File not found"));
    }
    return (fs::read($path), null);
}

// Caller
$content, $err = read_file_go($path);
if $err != null {
    echo "Error: {$err}";
    return;
}
process($content);

This style is more verbose but some teams prefer the explicit error variable.

Retry Patterns

Combine error handling with loops:

fn fetch_with_retry($url string): !Response {
    for $attempt in 0..3 {
        $response = http_get($url) catch $err {
            if $attempt == 2 {
                return error.wrap($err, "Failed after 3 attempts");
            }
            sleep(Duration::secs(1));
            continue;
        };

        return Ok($response);
    }

    return error("Unreachable");
}

Error Types

You can define custom error types:

enum DatabaseError {
    ConnectionFailed { host: string },
    QueryFailed { query: string, reason: string },
    NotFound { table: string, id: i32 }
}

fn find_user($id i32): Result<User, DatabaseError> {
    // ...
}

Custom error types enable exhaustive matching:

match find_user($id) {
    Ok($user) => show($user),
    Err(ConnectionFailed { host }) => retry_connection(host),
    Err(QueryFailed { query, reason }) => log_query_error(query, reason),
    Err(NotFound { .. }) => create_new_user($id)
}

The error Trait

Any type can be an error if it implements the Error trait:

type MyError {
    message string
    code i32
}

impl Error for MyError {
    fn message(&$this): string {
        return $this.message;
    }
}

Assertions and Panics

For programmer errors (bugs), use assertions:

fn get_index($arr &[i32], $i i32): i32 {
    assert($i >= 0 && $i < $arr.len(), "Index out of bounds");
    return $arr[$i];
}

Use panic() for unrecoverable situations:

fn must_have_config(): Config {
    $config = load_config() or {
        panic("Configuration required");
    };
    return $config;
}

Panics are for bugs, not for expected errors. Expected errors should use !T.

Best Practices

Use error unions for expected failures. Network errors, file not found, invalid input—these should use !T.

Use panics for bugs. Invariant violations, "impossible" states—these are programmer errors.

Add context when wrapping. error.wrap($err, "while processing user") is more helpful than just propagating.

Keep error messages actionable. "Failed to connect to database at localhost:5432" is better than "Database error."

Don't over-catch. Let errors propagate when you can't do anything useful with them.

// Bad - catching just to re-throw
$data = fetch() catch $err {
    return error($err);
};

// Good - just let it propagate
$data = fetch();

Match exhaustively on custom error types. The compiler will tell you if you miss a case.

Copyright (c) 2025 Ocean Softworks, Sharkk