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).

kindmeaningextra fields
parsemalformed data: JSON, CSV, TOML, YAML, regex, hex, gzip, an image, a time stringformat, path
fsfile IOpath
ioreading or writing a stream, and terminal IO
limita configured ceiling refusing to allocate or expandlimit, env, path
netHTTP, socket, WebSocketurl, host
sqldatabase errors
closedsend on a closed channel
cryptohash.decrypt refused: wrong key, wrong aad:, altered or malformed sealed data
budgetECKO_AI_MAX_CALLS exhaustedcalls, max, scope
capabilitypackage denied a gated operationcapability, package
cancelledtask cancellation
aia provider call failed or answered with something unusable
timeouta deadline passed: with_timeout, a py() call, a stream readfunc, ms
proca child process: spawn, signal, a closed pipe
osthe operating system refused something
archivezip or tar refused an entry
signalsignal handling
watchfilesystem events
assertan assert in a program, or a failed test case
skiptest.skip ending a test case as skipped
buga 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: the try can 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 the try can throw, so it never runs. A misspelling gets a suggestion.

It stays quiet where it cannot be sure:

  • when the try calls a function held in a variable, passes a callback (to map, pmap, reduce, sql.transaction and the like), awaits a task, calls py(), or throws error(v) whose kind is not written out;
  • about limit, budget, cancelled and capability, which can come from almost anywhere and are never required. timeout is not one of these;
  • about unreachable arms when the try loops over something that may be a stream, since a stream raises whatever its source does;
  • when the catch has no match on the kind at all. A plain catch (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.