The two failure tiers — recoverable values and aborts — the operators that bridge them
(? ?? ?. ! raise assert guard), and how errors are handled by type. Part of the
Language Reference. Also in 繁體中文.
Failure comes in two tiers, with exactly one bridge each way. Recoverable failure is a value —
absence and expected errors are ordinary values of a sum type, never a magic null; this is the tier
you work in day to day. A bug is an abort — overflow, division by zero, a wrong !, a failed
assert, or an explicit raise raise and unwind the stack (see Aborts below); they appear in no
signature and can't be inspected or resumed. Both tiers carry the same Err (the Error spec), so they
meet cleanly at the bridges: raise (and !, and assert) lift a value into an abort, guard
demotes an abort back into a value.
The value tier is one sum type; by convention the left is the value, the right is what gets propagated:
Either[X, Y]— anXor aY; the sides must differ (Either[T, T]is rejected), and an injection that could reach both sides is a compile error (construct the variant explicitly).Result[T]=Either[T, Err], whereErris theErrorspec (any implementing type).T?=Either[T, nil];nilis its placeholder value.
? — propagate. x? unwraps the left value, or early-returns the right from the enclosing
function (sugar for that early return), so the function must share the same right type. There's no
implicit bridge between T? and Result: convert first with opt.ok_or(err) or res.ok().
fn load() -> Result[Config] {
txt := read_file(path)? # Result[str]; an Err early-returns
return parse(txt) # parse -> Result[Config]
}
? works on every carrier: the enclosing function must answer one with the same right, and the
right travels unchanged, so an Err keeps its kind across the propagation.
?? — default. a ?? b yields a's left value if present, else b (right discarded); it
short-circuits, chains right-to-left, and works on any Either.
?. — optional chain (T? only). a?.b reads .b when a has a value, else short-circuits
the chain to nil in place (unlike ?, never returns from the function); use on any non-T? type
is a compile error. A field that is itself optional flattens — the chain answers that field's
type rather than a nested T??, which is not a type the language can write. This holds in
both compilers.
! — force-unwrap (value → abort). x! unwraps the left value or raises on an absent optional —
the deliberate "I know it's set" hatch, a crossing from the value tier into an abort. (Logical negation is
the keyword not, so postfix ! is free.)
[not yet]
UnwrapErroras a distinct,is-testable error kind is not built;err is UnwrapErroris E9078 NotImplemented:is UnwrapError, and today the abort fires with a generic message and is not one of the six taxonomy kinds below.
port := lookup("PORT") ?? 8080
name := env("NAME") ?? env("USER") ?? "anon"
addr := config?.server?.host ?? "localhost"
raise — abort with any Err (value → abort). raise e escalates an Err into an abort carrying
it — the general producer-side crossing that ! specialises. Keep the value tier (Result / Either
in the signature) for expected, recoverable failure; raise is for the unrecoverable — a broken
invariant, a failed assertion, a "can't happen" — so it enters no signature and is caught only by guard.
A raise e from cause form records cause as e's unwrap() — a nested abort that wraps a
lower-level Err in a higher-level one without losing it, feeding the same cause chain every Error
exposes; a bare raise e carries e unchanged. A raise statement takes the postfix guard every
other diverge takes — raise e if c, and raise e from cause if c — which is sugar for if c { raise e }
and is what the formatter's F401 rewrites the block form into. It is the statement form only: a raise
on the right of a ?? takes no trailing if, since the guard would read as the coalesce's.
assert cond — state a claim (value → abort). It raises AssertionError when the claim does not
hold, and it takes a condition and nothing else (GRAMMAR#assert-stmt): the message
is the one thing a function could never assemble, so the compiler writes it from the position, the claim's
own source text, and the value of each operand the comparison came apart into.
AssertionError: parse.zg:41 assert lo == hi
lo = 1
hi = 2
So assert is for the claim whose own text is the explanation. A claim needing an explanation rather than a
display is raise ValueError("why") if not cond, which is the production form — and the only conditional
one, since assert is the one diverge that takes no postfix if: assert c if d does not parse
(E2005). assert is always compiled in and there is no flag that strips it, because a program with and
without its assertions would be two programs; so zerg lint reports one outside a *_test.zg file as
L602 — not a weaker check than you meant, a less specific one.
The built-in error taxonomy is a TREE. This phase ships a fixed set of six error kinds, and some of them are kinds of others:
ValueError a value was not one this could accept
├── OverflowError …because it was outside the range int(u), byte(300), a + b
└── EncodingError …because it was not valid text a str bridge over bad UTF-8
IOError the world would not cooperate
IndexError an index outside a container
KeyError a key no map holds
in reads the tree; is does not. There are three relations here and each has its own spelling:
| Ask | Write | Of an OverflowError |
|---|---|---|
| is it exactly this kind | e is X |
is OverflowError |
| is it a member of this kind | e in X |
in ValueError too |
| what was it raised from | e.unwrap() |
whatever from named |
So a handler catches coarsely with e in ValueError ("the value was wrong, I do not care why") or precisely
with e is OverflowError, and the coarse one is not a lie. Zerg has no inheritance and this is not it: the
tree is a fixed relation between the built-in kinds, read upward only — every OverflowError is
in ValueError, and a ValueError raised by hand is not an OverflowError.
Keeping them apart is not tidiness. One predicate answering two of them distinguishes neither:
raise IOError("outer") from ValueError("inner") must stay tellable from an error that simply is a
ValueError, and it is only tellable while the question about causes is a different question.
You choose from these; defining your own error type (a struct / enum implementing the
Error spec —
message() -> str, unwrap() -> Err?, code() -> byte?, see Built-in specs) is not yet
supported. Each kind is a full Err: construct one with a message (raise ValueError("bad input")), let it
sit in a Result's right and be raised, read err.message(), test it with err is ValueError
and err in ValueError, and have
guard reify it back as Right(err) with message, cause, and code intact. The runtime's own intrinsic
failures raise the matching kind, so library and runtime errors share one vocabulary: a failed integer parse
is a ValueError (out of range → OverflowError), a checked narrowing conversion an OverflowError, an I/O
failure an IOError, a str bridge over invalid UTF-8 an EncodingError, an out-of-bounds index an
IndexError, a missing map key a KeyError. A Result / Either carries them and ? / ?? / guard
thread them unchanged; a match on a concrete Either[T, Kind] distinguishes the kind. The abort
contract itself — the message written to stderr, exit status 1, the Kind: message line — is
Conformance. The prefix belongs to that line, not to the message: message()
answers the text alone, and the kind is rendered in front of it when the error reaches the top, for a
raise ValueError("bad input") exactly as for the runtime's own IndexError.
code()is built, answersbyte?, and answers it absent, always. NoErrthis compiler can construct carries a code: the errors that exist are the built-in kinds, and a code belongs to a user-defined error type, which is the half of this paragraph with no implementation behind it. The method is complete over an empty domain rather than a form a program can reach and be refused for;message()andunwrap()answer as specified.
Aborts. An abort — a built-in runtime fault or any Err you raise — marks a bug, not an
expected failure. Of the fault names this chapter uses, eleven reify as is-testable kinds today:
ValueError, OverflowError, IOError, EncodingError, IndexError, KeyError, DivideByZeroError,
the three the concurrency chapter names — SendOnClosedError, DeadlockError and StopIteration — and
AssertionError, which a failed assert raises and nothing else does. That exclusivity is what the
kind buys over a message: zerg test reports a claim that did not hold as a failure and anything else
that reached the top of a test body as a crash, and it tells them apart by asking e is AssertionError.
The rest cannot be named at the surface yet: UnwrapError, MatchError and AliasError are
[not yet] — err is AliasError is E9078 NotImplemented: is AliasError — an is test compares the
operand's own type against a named one here, and AliasError names no type to put on the right of it — and
the abort carries no distinct reified kind for them, only a generic message. The refusal is what keeps them
out of the fold below: a kind the taxonomy HAS and this compiler cannot name must not read as a confident
"no, it is not one of those".
StopIteration is testable but not constructible. It is the one name a program may put on the right
of is and may not call: raise StopIteration("…") is E4063 StopIteration is testable but not
constructible in both compilers, because it is the marker a channel's clean close already wears —
see Concurrency for what a sender able to raise it would cost its consumer.
StackOverflowError is the one that is not an abort (see below). An abort is not
catchable as control flow: no try/catch,
no inspecting which abort fired, no resuming the failed expression. Semantically it is a stack
unwind that runs scope cleanup — every scope from the raise point to where it stops runs its
defers and is freed in order, its Ref values (channels and Ref[T]) refcount-decremented, exactly
like a normal scope exit; never a bare abort(). An unwind that reaches the top of its stack crashes
that stack: the main stack ends the program, a coroutine's stack ends only that coroutine (spawn is
fire-and-forget — see Concurrency).
An abort that fires while another is already unwinding — a defer, or a Ref drop, that itself
aborts — never abandons that unwind: the remaining defers still run, so cleanup is never skipped. The
two errors combine by the same nesting as raise e from cause — the later abort propagates with the one
already in flight recorded as its unwrap() cause — so neither is lost and the consumer reads the whole
chain. No error silently wins, and there is no separate suppressed slot to consult.
A StackOverflowError is the one end that is not an abort. Zerg does not optimize tail calls —
for is the loop, so a bounded stack is enough — which makes an unbounded recursion a definite overflow
rather than a silent hang, and the runtime gives that overflow its name: its fault handler writes
StackOverflowError: stack overflow to standard error and the process exits with status 1, on both
stacks a program can overflow, a coroutine's guard page and main's native stack.
What it cannot do is unwind. The stack that would run the pending defers is the exhausted one, so they
are skipped, no guard can demote it, and err is StackOverflowError is unwritable — there is no
value to ask about. It is also the one end a coroutine does not contain: an ordinary abort with no
handler ends only that coroutine, while an overflow ends the process. See
Conformance for the contract this sits outside of.
[implementation-defined] HOW an overflow is detected is this implementation's. A runtime that owned and grew its own stacks could check call depth before the fault and unwind cleanly; this one takes the fault and names it. What the specification pins is that the depth is bounded and that reaching the bound ends the program by name — not the mechanism that notices.
A DeadlockError — every coroutine blocked with no progress possible — is now the clean abort the spec
asks for: it unwinds, runs the pending defers, and a guard catches it. It is raised on main's
coroutine and re-raised on every detection rather than once, so a guard that retries unchanged turns
the deadlock into a livelock; see Concurrency for why both are deliberate.
guard — demote an abort to a value (abort → value). guard { … } runs a block and reifies any
abort inside it as an Err, so the expression is always a Result[T] (T = the block's value
type): a normal result v becomes Left(v), an abort carrying err becomes Right(err).
n := guard { parse_int(untrusted) } ?? 0 # an overflow inside becomes Right(err); ?? then defaults
fn read_config(s: str) -> Result[Config] {
return guard { risky_parse(s) } # an abort inside demotes to Right(err)
}
One limit on what a guarded block may be, and it is loud. A
return,breakorcontinuethat LEAVES the block is refused in both compilers — E9043 NotImplemented:returnleaving aguardblock — the guard's handler would stay installed on a frame that has returned. The handler is pushed before the block and popped after it, so a jump in between takes the frame away and leaves the handler on it. A loop written inside the guard is unaffected: itsbreaknever crosses the frame.
A block whose value is a name bound inside it is ordinary, which is most of what a guard holds
several statements for — guard { x := parse(s) check(x) }. The value is copied out rather
than aliased, so the bindings are still released on the way past and the answer outlives the block.
Result[T] survives in a signature, so read_config above is an ordinary function and what it returns
is an ordinary value at the call site: hand it to ?? for a default, to a match for the two sides (the
Right(e) arm's e is ValueError answers the kind), or to ? to propagate the same right out through the
next function's boundary unchanged.
The Result is always flattened: because a raised error is itself an Err, guarding a block
that already yields Result[U] still yields Result[U] — an internal abort and a returned
Right(err) collapse to the same Right(err). guard catches only aborts on the current stack;
a coroutine spawned inside the block has its own stack and is untouched.
guard is the sole way back from the abort tier, mirroring raise/! as the ways in — once guarded, an
abort is an ordinary Result handled by the same ? / ?? / match, with no separate handler and
no recover construct. It carries no special meaning in a coroutine: a coroutine body wrapped in
guard is just a function producing Result[T], and reports it by sending over a channel like any
other value.
Both tiers deliver the same erased Err — a value-tier Right(err) and a guard-reified abort are
indistinguishable — so one mechanism dispatches on either. To act on a specific error, test its type
with is (Type tests):
match guard { work() } {
Either.Left(v) => use(v)
Either.Right(e) => {
# a taxonomy kind, then a built-in abort the guard reified, then the mandatory catch-all
if e is IOError { rebuild() } else if e is OverflowError { alert(e) } else { report(e.message()) }
}
}
is yields only a bool, so a branch may use the Error interface (message / code / unwrap)
but not the concrete type's own fields — the value was erased and is never re-constructed. On an error
that is what is is FOR: the eleven reified kinds above are the only names it answers from a runtime tag,
and a taxonomy name outside them is E9078 rather than a test that answers false. Every OTHER name is
answered from the operand's own type as a compile-time constant, which
Specs & Generics decides — a boxed operand being the one whose
answer is read at runtime, off the witness table it was boxed against.
The set of errors reachable here is treated as open for coverage, so an is-chain can never be
exhaustive: a catch-all is mandatory. An unmatched error would abort like any uncovered match — but
MatchError is [not yet] a reified kind, and because the final match arm is always unconditional the
compiler never emits one today (the catch-all requirement is a static rule, not a runtime MatchError).
This splits error handling by whether you own a closed set. When you need an error's kind decided by
value, keep it concrete — an Either[T, ValueError] (never erased) whose right a match reads by value.
When you only recognize a few kinds, take the erased Result[T] and is-dispatch with a catch-all. So the
return type is a contract: an erased Result says "branch and use the Error interface"; a concrete Either
says "here is my exact error kind". (A user-defined error enum gathering several kinds into one closed sum
is deferred with user error types above.)
[not yet] A built-in error kind cannot appear in a type position:
fn f() -> Either[int, ValueError]reports E4056 no type namedValueError. The kinds are constructors the runtime knows, not names the type checker resolves, so the closed-set half of the split just described has no program that can be written today; the erasedResult[T]with anis-chain is the one of the two routes that exists.