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.
finally is cleanup, not a handler. It runs whether the try succeeded, failed or left early. With a catch, the catch decides what happens to the error. Without one, the error carries on to the caller once the finally has run:
fn save(path, data) {
lock = acquire()
try {
fs.write(path, data) # if this throws...
} finally {
release(lock) # ...the lock is released, and the caller
} # still gets the error
}
If the finally block itself throws, that error replaces the original one.
A try with neither a catch nor a finally ignores the error. If the body fails, the try evaluates to null; if it succeeds, to the body's value:
thumbnail = try { image.load(path) } # null if the file is missing or broken
It is shorthand for try { … } catch { null }. Use it only when a failure really means "nothing here". It turns every error into null, a programmer mistake included, so a typo inside it fails silently. When only some failures are expected, catch them by kind and pass the rest on with error(e) (see Dispatching).
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 {kind: "bug", message}. Wrong argument types, arity errors, out-of-bounds indexing, a missing field, undefined names. You fix these rather than dispatch on them, so they share one kind, and ecko check catches most before the program starts. Every runtime error you catch is a map with a kind and a message, so a handler can always log e.message. A value you throw yourself with error(v) is caught exactly as you threw it.
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 - and on the kinds a try can throw that its catch does not handle (see Checking your handlers).
| 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 | |
crypto | hash.decrypt refused: wrong key, wrong aad:, altered or malformed sealed data | |
budget | ECKO_AI_MAX_CALLS exhausted | calls, max, scope |
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: with_timeout, a py() call, a stream read | func, ms |
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 | |
skip | test.skip ending a test case as skipped | |
bug | a programmer mistake: a wrong type or arity, an index out of bounds, a missing field |
Dispatching
try {
resp = http.get(url)
data = json.decode(resp.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 when the value might be one you threw yourself with error("..."): get is total, so the same match handles a string, where kind is null. Every error the runtime throws has a kind.
Re-throwing the default case is the important habit: a handler that swallows everything turns a programmer mistake into silence.
Checking your handlers
ecko check and your editor work out which kinds each try can throw and compare them with the arms of a match on the caught error's kind. It knows what every standard-library function throws, follows your own functions through the whole program (and into files and packages you import), and picks up your own kinds from each error({ kind: "...", .. }).
import std.json
fn load(path) = json.read(path) # can throw fs or parse
try {
load("config.json")
} catch (e) {
match get(e, "kind") {
"parse" => print("bad config")
"net" => print("offline") # warning: nothing here throws net
} # warning: load can throw fs
}
Two warnings come out of it. Neither stops a run.
unhandled-error-kind: thetrycan throw a kind that has no arm, and there is no_arm. Add the arm, or_ => error(e)to pass the rest on.unreachable-error-kind: an arm names a kind nothing in thetrycan throw, so it never runs. A misspelling gets a suggestion.
It stays quiet where it cannot be sure:
- when the
trycalls a function held in a variable, passes a callback (tomap,pmap,reduce,sql.transactionand the like), awaits a task, callspy(), or throwserror(v)whose kind is not written out; - about
limit,budget,cancelledandcapability, which can come from almost anywhere and are never required.timeoutis not one of these; - about unreachable arms when the
tryloops over something that may be a stream, since a stream raises whatever its source does; - when the
catchhas nomatchon the kind at all. A plaincatch (e) { log(e) }handles everything.
Handling a kind inside a function removes it from what callers see, and _ => error(e) passes on exactly the kinds the other arms did not take:
fn load(path) = try { json.read(path) } catch (e) {
match get(e, "kind") {
"parse" => {} # a bad file means "no settings"
_ => error(e) # callers still see fs
}
}
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". An uncaught error prints its message (a map's message field when present), the line that raised it, and the call stack, outermost call first:
error: map has no key 'missing'
--> h.ecko:2:6
|
2 | x.missing
| ^
call stack (outermost call first):
1. f at app.ecko:4:2
h.f(v)
2. inner at h.ecko:6:5
inner(x)
Every location is file:line:col, and it is where the error was raised: inside an imported module, inside a callback that map or pmap ran, or inside an async fn you awaited. A call is located at what it calls - inner in inner(x), the . of h.f(v) - and a call that is rejected before the function runs (the wrong number of arguments, a parameter's type, @requires) or as it returns (the return type, @ensures) is raised at the call, so the stack does not list the function that was rejected.
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.