Error handling has a dishonesty problem, and every familiar answer pays a price somewhere. Exceptions make the error path invisible: any call might throw, no signature says which, and the cleanup blocks pile up defensively. Error-return conventions make the path visible and pay with the pyramid — a check after every call, and nothing but discipline stopping an error from being dropped on the floor. Result wrappers make dropping harder and pay in ceremony — unwrap, match, thread-through — and the error’s journey still vanishes the moment you need to know where it came from.
Mica’s answer is the failure channel: the compiler carries “value or error” as a second, invisible result beside the first, and the language surfaces it in exactly five places — one word in the signature, one statement to raise, and three-and-a-half clauses at the call site. This tutorial walks all of them, in one compiled example, and ends with the seven programs the compiler refused along the way.
It assumes defer — cleanup and error propagation turn out to be one mechanism, and that is the best part of the story.
Two kingdoms
Mica splits what can go wrong into two kingdoms, and never lets them share a mechanism:
| A bug | An expected error | |
|---|---|---|
| Example | index out of bounds, nil dereference, overflow | file not found, sensor unplugged, text does not parse |
| Meaning | the program is wrong | the program is fine; the world declined |
| What happens | a loud stop at a named line, on every optimization level | a typed code travels the failure channel |
| Recoverable | never — continuing would compute with corruption | always — it is an ordinary value |
Everything below is the second kingdom. The first kingdom’s story is told in
the heap proofs: bugs abort, deterministically, in
release exactly as in debug. No on fail clause can catch an index trap —
that is a promise, not a limitation.
A domain, and a signature that admits it can fail
An error domain is declared like an enumeration with error in front, and a
fallible function carries fails and the domain in its signature:
type
SensorError = error (Disconnected, OutOfRange);
function ReadGauge(raw : int64) : int64 fails SensorError;
begin
if raw < 0 then
fail Disconnected;
if raw > 100 then
fail OutOfRange;
ReadGauge := raw;
end;Three facts carry the design. A code is a plain ordinal value — one machine
word, no heap, no message string — so raising costs a register write, and the
embedded profile pays nothing it cannot afford. The domain is part of the
function’s type: ReadGauge can raise exactly these two codes and no other,
and the compiler holds it to that. And fail is a statement that ends the
activation on the spot — raising is visible, local, and total, the
error-kingdom sibling of assigning the result name.
fail outside a fails function is refused at the source:
analyzer error 5302: 'fail' raises on the enclosing function's failure
channel, so it requires a signature declaring 'fails': 'F' cannot failConsumption is not optional
Here is the rule everything else hangs on: a call to a fallible function must say what happens on failure. Not as a warning, not as a lint — as a compile error:
analyzer error 5304: a call to the fallible function 'F' must consume its
failure channel: propagate with 'on fail leave', default with 'on fail use
<expression>', handle with 'on fail e do <statement>', or discard a
procedure's failure with 'on fail continue' — an unconsumed failure cannot
existThe message is the whole menu. Four clauses, all starting with the same two words, all postfix on the call they consume.
on fail use — the visible default
level := ReadGauge(250) on fail use 100;Default: a fallback the reader can see
clamped reading: 100The failure is discarded and the fallback lands in the destination — a thing
every language allows, but here it is spelled out at the call, so a code
review sees every swallowed error at a glance. On success the clause stands
ready and never runs; ReadGauge(42) on fail use -1 printed 42 in the same
run.
on fail e do — a name and a decision per code
var
e : SensorError;
level := ReadGauge(-5) on fail e do
case e of
Disconnected : level := 0;
OutOfRange : level := 100;
end;The binding e is a variable the program declares itself — the for
statement’s control-variable rule transplanted, so no invisible scope exists —
and the handler is one statement, which in practice is a case. And over an
error domain, case plays by a harder rule than anywhere else in the
language: coverage is a compile error, not a warning. Drop the
OutOfRange arm and:
analyzer error 5300: a case over the error domain 'SensorError' must consume
every code or carry an else: 1 of 2 codes are covered — an unhandled error
code is the exact silent gap the failure channel exists to closeThis is the API-evolution story most error systems dream about: add a code to a domain, and every handler that has not decided what the new failure means refuses to build. The domain declaration is the single place a failure story is extended, and the compiler walks every handler for you.
on fail leave — propagation that runs your cleanup
function SumOfGauges(a : int64, b : int64) : int64 fails SensorError;
var
first : int64;
second : int64;
begin
first := ReadGauge(a) on fail leave;
second := ReadGauge(b) on fail leave;
SumOfGauges := first + second;
end;on fail leave forwards the callee’s failure on the enclosing function’s own
channel — one clause, and it names the mechanism it rides: leave, the
ordinary early exit, which means every pending defer runs before the
code travels up. Propagation and early return are the same exit, so
cleanup needs no second mechanism beside it.
Propagate: a failure crosses two calls
sum: 100
sum with a bad gauge: -1Two rules keep propagation typed end to end, each with its refusal. The enclosing function must itself be fallible:
analyzer error 5306: 'on fail leave' forwards the failure on the enclosing
function's own channel, so 'the program body' must itself declare 'fails'and the domains must match — a failure never shape-shifts silently between domains:
analyzer error 5307: the failure cannot forward across domains: the callee
fails 'FileError' but the enclosing function fails 'NetError'; handle and
convert explicitly insteadConversion is an on fail handler whose statement re-raises on the caller’s
own domain — visible, per code, and exhaustive like every other error case.
That story, and everything else about domains, gets the
next tutorial to itself.
on fail continue — the procedure’s swallow
A procedure has no destination to default into, so its deliberate swallow is its own word:
LogReading(-1) on fail continue;Still a visible token at the call — silence remains impossible — and continue
says exactly what happens: execution continues on the next statement.
Cleanup rides the failure exit
LogReading registers its cleanup with defer, reads the gauge, and reports:
procedure LogReading(raw : int64) fails SensorError;
var
reading : int64;
begin
defer WriteLn(" gauge session closed");
reading := ReadGauge(raw) on fail leave;
WriteLn(" gauge answered %lld", reading);
end;Cleanup rides the failure exit
gauge answered 55
gauge session closed
gauge session closedThe first two lines are the success road. The third is the failure road: the
gauge answered nothing, the failure forwarded — and the session still closed,
because a fail is an activation exit and defer’s rules
apply unchanged. Nothing about error handling had to be invented for cleanup,
and nothing about cleanup had to learn about errors.
The trace a debug build records
One thing error values classically lose — and exceptions buy at the cost of
unwinding machinery — is the journey: where did this failure enter, and
through what? In debug and checked builds, Mica records it. The raise seeds a
per-task ring, every on fail leave hop appends its position, and a handler
can print the chain:
level := SumOfGauges(40, 999) on fail e do
begin
FailureTracePrint();
WriteLn(" hops recorded: %lld", FailureTraceDepth());
level := -1;
end;On stdout, the depth; on stderr, the road itself:
Mica failure trace: raised at ReadGauge:50
Mica failure trace: via SumOfGauges:64Release builds plant no recorder at all — zero bytes, zero cycles — so the feature exists exactly where a developer is looking and costs exactly nothing where they are not.
What the compiler refused
Every claim above was proved by a program that does not build. The seven, in one table:
| The program tried to | The compiler said |
|---|---|
| call a fallible function with no clause | 5304: … an unconsumed failure cannot exist |
case over a domain, one code missing | 5300: … 1 of 2 codes are covered |
fail in a non-fallible function | 5302: … 'F' cannot fail |
on fail leave in a non-fallible caller | 5306: … must itself declare 'fails' |
| raise a code from a foreign domain | 5303: the raised code must be a code of the enclosing function's fails domain 'FileError', found: NetError |
| propagate across two different domains | 5307: … handle and convert explicitly instead |
| bind the caught code to an undeclared name | 5018: identifier not found: e |
What this does not do
Honesty about the edges, as always in this series:
- A bug is not catchable. No clause intercepts an index trap, a nil
dereference, or an overflow — the first kingdom aborts on every tier, by
design. An
on failhandler is for the world declining, never for the program being wrong. - A code carries no payload. A domain code is an ordinal, not a struct — there is no message string, no wrapped cause, no allocation. The trace covers the “where”, the domain and code cover the “what”; a program that needs more context passes it through its own results.
- One domain per signature. A function fails one named domain, not a list and not an inferred set. Crossing domains is always an explicit, visible conversion — the next tutorial shows the idiom.
- Allocation failure is not on this channel. Heap exhaustion is treated as the first kingdom: it stops the program at once. The embedded profile sidesteps the question entirely — a fixed arena fails loudly at its budget, at a named line.
- The trace is a debug and checked feature. A release binary records nothing; print the chain where you develop, not where you ship.
Try it
git clone https://gitlab.com/mica-lang/mica-container.git
make -C mica-container/examples/FailureChannel runDelete the OutOfRange arm from the handler’s case and rebuild — the
compiler names the uncovered code. Then add a third code to SensorError and
watch every handler in the file refuse until it decides what the new failure
means. That refusal is the feature.
Next
Error domains — the ordinal family a domain rides
(loops, arrays, Succ/Pred), the explicit conversion idiom between
domains, and the boundary story: a C function that returns -1 and sets
errno imported as an honest fails function, with no -1 and no errno
anywhere in your program.