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 times

Two 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 LoadError
analyzer 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 signature

Why 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: -2

The 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, twice

Close 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:

ConventionThe C pattern it liftsTypical functions
negative_errnoreturn < 0, then errnoread, write, close, the socket family
null_errnoNULL return, then errnothe fopen class
sentinela stated sentinel value, then errnommap 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 toThe compiler said
assign a NetError value to a LoadError variable5143: incompatible data types in assignment
declare the same code twice in one domain5298: error code identifier already declared
write fails int645301: 'fails' requires an error domain type … declare the domain as 'T = error (A, B, C);'
convert with an incomplete case5300: … must consume every code or carry an else
propagate across two domains5307: … handle and convert explicitly instead

What this does not do

  • OsError is curated and wide. Your case over a lifted failure names the codes your program distinguishes and carries an else for the rest of the operating system’s imagination — the one place an error else is 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 run

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