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
- Only works in functions that return
!T: You can't auto-propagate in functions that don't return an error union.
- Type compatibility: The error propagates up the call stack until caught or returned.
- 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:
- Simplicity:
!Tis easier thanResult<T, E> - Safety: Can't ignore errors—they propagate automatically
- Flexibility: Choose explicit handling when you need it
- Ergonomics: No boilerplate for the happy path
- 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.