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 bugAn expected error
Exampleindex out of bounds, nil dereference, overflowfile not found, sensor unplugged, text does not parse
Meaningthe program is wrongthe program is fine; the world declined
What happensa loud stop at a named line, on every optimization levela typed code travels the failure channel
Recoverablenever — continuing would compute with corruptionalways — 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 fail

Consumption 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
exist

The 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: 100

The 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 close

This 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: -1

Two 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 instead

Conversion 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 closed

The 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:64

Release 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 toThe compiler said
call a fallible function with no clause5304: … an unconsumed failure cannot exist
case over a domain, one code missing5300: … 1 of 2 codes are covered
fail in a non-fallible function5302: … 'F' cannot fail
on fail leave in a non-fallible caller5306: … must itself declare 'fails'
raise a code from a foreign domain5303: the raised code must be a code of the enclosing function's fails domain 'FileError', found: NetError
propagate across two different domains5307: … handle and convert explicitly instead
bind the caught code to an undeclared name5018: 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 fail handler 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 run

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