Error handling & the error dialect
try {
risky()
} catch (error) {
print("Error: {error}")
} finally {
cleanup() # always runs
}
try/catch catches runtime errors only. return, break and continue unwind through it.
One dialect, four rules
Ecko has exactly one story per failure class, so you can predict what any API does:
1. Absence returns null - but only from the lookups documented as nullable: get(xs, i), get(m, k), os.env(...). Everything else is strict, so xs[9] and m.missing raise at the site of the mistake.
2. Operational failures throw { kind, message, ... } maps. Anything that fails because of the world - malformed data, a missing file, network trouble, a closed channel, an exhausted budget - throws a map with a stable kind, a human-readable message, and context fields.
3. Programmer mistakes throw prose strings. Wrong argument types, arity errors, out-of-bounds indexing, undefined names. You fix these rather than dispatch on them, and ecko check catches most before the program starts.
4. Ok/Err and Some/None are ordinary data types for your own match-based modelling. They are not the error channel; nothing in the standard library returns them.
The standard library's kinds
This is the whole set. A release fails if the code throws a kind missing from this table, or if the table names one nothing throws, so it cannot drift out of date the way it did before that check existed. ecko check also warns on a match arm naming a kind that is not here - a typo used to be an arm that simply never ran.
| kind | meaning | extra fields |
|---|---|---|
parse | malformed data: JSON, CSV, TOML, YAML, regex, hex, gzip, an image, a time string | format, path |
fs | file IO | path |
io | reading or writing a stream, and terminal IO | |
limit | a configured ceiling refusing to allocate or expand | limit, env, path |
net | HTTP, socket, WebSocket | url, host |
sql | database errors | |
closed | send on a closed channel | |
budget | ECKO_AI_MAX_CALLS exhausted | calls, max |
capability | package denied a gated operation | capability, package |
cancelled | task cancellation | |
ai | a provider call failed or answered with something unusable | |
timeout | a deadline passed: a py() call, a stream read | func |
proc | a child process: spawn, signal, a closed pipe | |
os | the operating system refused something | |
archive | zip or tar refused an entry | |
signal | signal handling | |
watch | filesystem events | |
assert | an assert in a program, or a failed test case |
Dispatching
try {
data = json.decode(body)
} catch (e) {
match get(e, "kind") {
"parse" => print("bad {e.format} payload: {e.message}")
"net" => print("offline: {e.message}")
_ => error(e) # a panic or unknown kind: re-throw
}
}
Use get(e, "kind") rather than e.kind. get is total, so the same match also handles a plain-string error, where kind is null.
Re-throwing the default case is the important habit: a handler that swallows everything turns a programmer mistake into silence.
Errors are first-class values
error(v) throws any value, and catch (e) binds it. For your own recoverable failures, throw a { kind, message } map so callers dispatch on your kinds exactly like the standard library's:
fn find_user(id) {
if id == 0 { error({ kind: "not_found", message: "no user 0" }) }
id
}
A plain error("boom") is caught as the string "boom". Uncaught errors render Runtime error: <message>, using a map's message field when present.
Secrets in errors
error(secret(x)) renders redacted while catch still binds the wrapped value - so an error path cannot leak a credential into a log. See Secrets.