The failure channel walked the clauses: how a
failure is raised, propagated, handled, defaulted. This tutorial is about the
thing that travels — the error domain — and it has three claims to make.
A domain is a first-class ordinal type, so per-code bookkeeping is an array
subscript, not a map. Domains are walled, so a failure crosses between layers
only through a conversion you can read. And the wall holds at the edge of the
language: a POSIX function arrives as an honest fails function, its C error
convention lifted by the compiler from the interop contract — no -1, no
errno, anywhere in your program.
One compiled example carries all three: ErrorDomains.
A domain is an ordinal
type
NetError = error (Timeout, Refused, Reset);
Attempts = array[NetError] of int64;A domain’s codes are ordinals in declaration order — Timeout is 0,
Refused is 1, Reset is 2 — and the domain joins the same type family as
enumerations and subranges. Everything that family can do, a domain can do:
bound an array dimension, drive a counting loop, select a case arm. The
example counts failures per code with no if and no map, because the caught
code is the array subscript:
for kind := Timeout to Reset do
tally[kind] := 0;
Probe(1) on fail kind do tally[kind] := tally[kind] + 1;
Probe(3) on fail kind do tally[kind] := tally[kind] + 1;
Probe(1) on fail kind do tally[kind] := tally[kind] + 1;A domain is an ordinal: the tally it indexes
clean answer: 40
code 0 failed 2 times
code 1 failed 0 times
code 2 failed 1 timesTwo smaller facts ride along. A fallible function may stand as a bare call statement — the result is discarded, the failure is still consumed; the consumption rule never blinks. And a code is only an ordinal: one machine word, no message, no allocation — which is why an error domain works on a microcontroller exactly as it works here.
The walls
A code belongs to exactly one domain, and domains never mix — not by assignment, not by comparison, not by propagation. The compiler holds each wall with its own sentence:
analyzer error 5143: incompatible data types in assignment to b:
NetError to LoadErroranalyzer error 5298: error code identifier already declared: Timeout
(first occurrence index 0)analyzer error 5301: 'fails' requires an error domain type, but 'int64' is
declared as 'primitive': declare the domain as 'T = error (A, B, C);' and
name it in the signatureWhy walls at all? Because the alternatives are well-trodden regrets: inferred error sets that bleed every low-level code into every top-level signature, and declared throw-lists that grow until nobody reads them. A Mica signature names one domain — the failure story of its layer — and the type system guarantees a socket error cannot masquerade as a payload error three layers up.
Conversion: one failure story per layer
Walls need gates. When Fetch fails LoadError calls Probe which
fails NetError, plain propagation is refused (5307, shown
last time) — so the handler cases over the callee’s
codes and each arm re-raises on the enclosing function’s own domain:
function Fetch(which : int64) : int64 fails LoadError;
var
ne : NetError;
got : int64;
begin
got := Probe(which) on fail ne do
case ne of
Timeout : fail IoFailed;
Refused : fail IoFailed;
Reset : fail BadPayload;
end;
Fetch := got;
end;The case must cover all of NetError — error-case completeness is a hard
error, always — so a conversion is a complete, reviewable translation
table between two failure stories, never a lossy funnel that happens to
compile. Add a code to NetError and every conversion out of it refuses to
build until it says what the new failure becomes.
Past the gate, a converted failure is an ordinary failure. Reload shares
Fetch’s domain, so plain on fail leave is legal again, and the caller
sees only LoadError:
Conversion: one failure story per layer
loaded: 40
refused socket surfaced as: -1
reset socket surfaced as: -2The refused socket surfaced as IoFailed, the reset one as BadPayload —
each layer speaking its own vocabulary, with the translation written down
once, at the boundary between them.
The lift: C’s errno arrives as a domain code
C has error conventions, not error types: close returns -1 and sets
errno, fopen returns NULL and sets errno, mmap returns (void*)-1.
Every language that imports C code re-implements the check-and-translate
dance by hand, per call site or per wrapper. Mica moves it where it belongs:
the interop contract declares the convention, and the compiler synthesizes
the dance behind the call.
imp
Close, Pipe, OsError, Ebadf, Unknown : posix;Close(9999) on fail osCaught do
WriteLn(" close of a dead descriptor failed with code %lld", Ord(osCaught));
case osCaught of
Unknown : WriteLn(" unknown");
Ebadf : WriteLn(" EBADF, as expected")
else WriteLn(" some other code")
end;The lift: C's errno arrives as a domain code
close of a dead descriptor failed with code 7
EBADF, as expected
a real pipe closed cleanly, twiceClose is a fails function to the importer — same clauses, same
consumption rule, same walls. Behind the call the compiler checks the
negative return, reads errno first (it survives only until the next libc
call, so ordering is not optional), and translates it to the curated
OsError domain. Note the 7: that is Ebadf’s ordinal in the domain,
not Linux’s errno number — the raw number stays at the boundary, like the
-1 did.
Three conventions cover most of C’s surface, each a one-line declaration in the contract:
| Convention | The C pattern it lifts | Typical functions |
|---|---|---|
negative_errno | return < 0, then errno | read, write, close, the socket family |
null_errno | NULL return, then errno | the fopen class |
sentinel | a stated sentinel value, then errno | mmap and its all-ones address |
No shipping language does this declaratively at the import boundary —
elsewhere the lifting is written by hand, call site by call site or wrapper
by wrapper — and the consequence here is visible in the example’s last
comment: no -1 is compared and no errno is read anywhere in the file.
What the compiler refused
| The program tried to | The compiler said |
|---|---|
assign a NetError value to a LoadError variable | 5143: incompatible data types in assignment |
| declare the same code twice in one domain | 5298: error code identifier already declared |
write fails int64 | 5301: 'fails' requires an error domain type … declare the domain as 'T = error (A, B, C);' |
convert with an incomplete case | 5300: … must consume every code or carry an else |
| propagate across two domains | 5307: … handle and convert explicitly instead |
What this does not do
OsErroris curated and wide. Yourcaseover a lifted failure names the codes your program distinguishes and carries anelsefor the rest of the operating system’s imagination — the one place an errorelseis the idiom rather than a smell.- Conversions are written by hand, on purpose. There is no
From-style automatic translation between domains: Rust’s invisible conversions are exactly how an error’s meaning drifts across layers unreviewed. A Mica conversion is a case you can point at in review. - The domain is closed. Codes are declared in the domain, all of them, in one place — there is no open-ended registration, because the completeness checking that makes handlers trustworthy depends on the domain being a closed set.
Try it
git clone https://gitlab.com/mica-lang/mica-container.git
make -C mica-container/examples/ErrorDomains runAdd a Halted code to NetError and rebuild: the tally loop keeps working
(it walks the domain, whatever its size), and the conversion inside Fetch
refuses until its table says what Halted becomes. Then change Close(9999)
to close descriptor 0 twice and watch a different OsError code arrive
through the same lift.
Next
The errors group is complete for now: the channel and its clauses in
part one, the domain, the walls and the boundary
lift here. The series continues with the push half of streams — channels,
select, and tasks that feed them.