Error Handling Done Right
Exceptions are invisible control flow. Lateralus makes errors visible, composable, and impossible to ignore.
The problem with exceptions
In most languages, any function can throw. You don't know which ones will until runtime. The call stack unwinds silently, skipping cleanup code, corrupting state. Checked exceptions (Java-style) tried to fix this but created a different mess — boilerplate catch blocks, forced wrapping, and throws Exception everywhere.
Go's approach (if err != nil) is explicit but verbose. Rust's Result<T, E> with ? is closer to what we want, but Lateralus takes it further with pipeline-native error propagation.
Result types in Lateralus
Every fallible function returns Result<T, E>. There are no exceptions. The type system forces you to handle the error case:
fn parse_port(s: String) -> Result<Int, ParseError> {
let n = s |> to_int()?
if n < 1 || n > 65535 {
Err(ParseError::OutOfRange("Port must be 1-65535"))
} else {
Ok(n)
}
}
The error pipeline operator |?>
The standard pipeline |> passes values through. The error pipeline |?> short-circuits on Err:
let config = read_file("config.toml")
|?> parse_toml()
|?> validate_config()
|?> apply_defaults()
If any step returns Err, the chain stops and the error propagates. No nesting, no if err checks.
try / recover / ensure
For structured error handling with cleanup:
try {
let db = Database::connect(url)?
let rows = db.query("SELECT * FROM users")?
rows |> map(format_user) |> println_each()
} recover err {
match err {
DbError::Connection(msg) => log::error("DB down: {msg}"),
DbError::Query(msg) => log::error("Bad query: {msg}"),
_ => log::error("Unknown: {err}"),
}
} ensure {
db.close() // Always runs, like finally
}
Custom error types
Define domain errors as enums. The compiler enforces exhaustive matching:
enum AppError {
NotFound(String),
Unauthorized,
RateLimit { retry_after: Int },
Internal(String),
}
Why this matters
Every error path is visible in the type signature. The compiler catches unhandled cases. Pipelines compose cleanly even with errors. And ensure guarantees cleanup without the fragility of destructors or defer statements.
Read the formal specification for the complete error handling semantics, or try it in the playground.
Error propagation in pipelines
The |?> operator is where error handling and pipelines intersect. It propagates errors through pipeline stages automatically:
// Without |?> — verbose error handling at each step:
let data = match read_file("config.toml") {
Ok(d) => d,
Err(e) => return Err(e),
}
let parsed = match toml::parse(data) {
Ok(p) => p,
Err(e) => return Err(e),
}
let validated = match validate(parsed) {
Ok(v) => v,
Err(e) => return Err(e),
}
// With |?> — errors propagate automatically:
let validated = read_file("config.toml")
|?> toml::parse()
|?> validate()
Each |?> stage checks the Result. If it's Ok, the value is unwrapped and passed to the next stage. If it's Err, the entire pipeline short-circuits and returns the error. It's the pipeline equivalent of Rust's ? operator.
Custom error types
Lateralus encourages defining domain-specific error types rather than using generic strings:
enum ScanError {
Timeout(host: String, duration: Duration),
ConnectionRefused(host: String, port: u16),
DnsResolutionFailed(hostname: String),
Unauthorized(host: String, reason: String),
}
// Pattern match on errors for precise handling:
match nmap_scan(target) {
Ok(results) => process(results),
Err(Timeout(host, dur)) => {
log::warn("Scan timed out after {dur}: {host}")
retry_with_longer_timeout(host)
},
Err(ConnectionRefused(host, port)) => {
log::info("Port {port} closed on {host}")
skip_host(host)
},
Err(e) => return Err(e), // Propagate other errors
}
The recover combinator
For pipelines where you want to handle errors mid-stream without breaking the chain, there's recover:
let results = targets
|> map(|t| nmap_scan(t)) // Vec<Result<ScanResult, ScanError>>
|> recover(|err| match err { // Handle errors inline
Timeout(h, _) => retry(h, timeout: 60.seconds()),
_ => Err(err), // Re-raise other errors
})
|> filter_ok() // Keep only successes
|> unwrap_all() // Vec<ScanResult>
Error context and chaining
Real-world errors need context. Lateralus supports error chaining with the context method:
fn load_config(path: String) -> Result<Config, Error> {
read_file(path)
|?> toml::parse()
|?> validate()
.context("Failed to load config from {path}")
}
// Error output:
// Error: Failed to load config from /etc/nullsec/config.toml
// Caused by: TOML parse error at line 42
// Caused by: Expected '=' after key 'database.host'
Each .context() wraps the inner error with additional information. When the error is displayed, the full chain is printed from outermost to innermost, giving you both the high-level context and the specific cause.
Comparison with other approaches
- Exceptions (Python, Java) — errors are invisible in the type signature. A function might throw 12 different exceptions and you won't know until runtime. Lateralus makes error types explicit.
- Error codes (C, Go) — easy to forget to check. Go's
if err != nilpattern is verbose and repetitive. Lateralus|?>is concise and the compiler warns if you ignore a Result. - Result types (Rust) — Lateralus took direct inspiration from Rust's
Result<T, E>. The main difference is|?>integrating with pipelines, and therecovercombinator for mid-pipeline error handling.