Status: Superseded by ADR-280 — its D1-D4 are D24-D27 there
Date: September 25, 2026
Target Version: Nikaia 0.0.187
Answers: the owner’s objection to ADR-210 D3 — every error
paying 24 bytes for the special cases is not our philosophy — and the question
that followed it: do we use the language below’s niche optimisation well? We
did not.
Corrects: ADR-210 D3’s the success path never sees an
envelope (§1)
Related: ADR-157 (Thrown<E>, the named channel),
ADR-170 (the list, built), ADR-115 D1 (what fills
it), ADR-023 D6 (an error knows its site) and §3.2 (why the trace
is off by default)
A named channel’s failure is a Thrown<E>: the author’s error, the site of the
throw, a trace where NIKAIA_TRACE asked for one, and the failures that
joined it. Each of the last three was a field of its own:
| field | bytes | filled by |
|---|---|---|
origin: &'static str |
16 | every throw |
trace: Option<Backtrace> |
48 | a process started with NIKAIA_TRACE |
secondary: Vec<Thrown<E>> |
24 | an overlap whose branches fail together, a failing cleanup |
ADR-210 D3 said the list costs nothing where nothing joins,
because an empty Vec allocates nothing and the success path never sees an
envelope. The second half is wrong. A function that can fail returns a
Result, and a Result is as large as its larger half whether it fails or
not: every call to fn load() -> Int throws ConfigError moved 96 bytes where
the bare error would have moved 16. So 88 bytes of every such call paid for
two special cases and a switch almost no process sets. That is the opposite of
what this language says about cost: a feature is paid for by the program that
uses it.
The language below has the tool for this, and it was not being used: a
Result needs no tag of its own where one of its halves has a value that can
never occur — a niche. A reference is never null. A field-less enum has
unused discriminants. Thrown<E> had niches only where the author’s E
happened to bring one, and three fields that brought none worth having.
size_of, x86-64, before and after this record; the bare column is what the
same Result costs with no envelope at all.
| before | after | bare | |
|---|---|---|---|
Thrown<E>, E a field-less enum |
96 | 16 | 1 |
Thrown<E>, E a unit struct |
88 | 8 | 0 |
Thrown<E>, E one Int |
96 | 16 | 8 |
Thrown<E>, E holds a String |
112 | 32 | 24 |
Site<E>, what a handler holds beside the error |
88 | 8 | — |
Raised, inside the boxed channel’s box |
104 | 24 | — |
Result<Int, Thrown<E>>, E a field-less enum |
96 | 16 | 16 |
Result<Int, Thrown<E>>, E a unit struct |
88 | 16 | 16 |
Result<Int, Thrown<E>>, E one Int |
96 | 16 | 16 |
Result<Int, Thrown<E>>, E holds a String |
112 | 32 | 24 |
Result<Int, Box<dyn Error>>, the boxed channel |
16 | 16 | 16 |
For the error types programs declare — an enum of cases, with or without a
word of payload — a function that can fail now costs exactly what it would
cost with no envelope at all. What remains is one word beside the error,
which is the site ADR-023 D6 says every error knows; it shows only
where the success value is smaller than a word (Result<(), Thrown<E>> is 16
bytes, the bare one 1) or where the error is already larger than the success
value.
nikaia_std::error::Tail, private to the module:
throw) or where something
joins (at the first join, from the site the word held). Only the special
cases allocate it, and they pay for it once, on the failure path.Both addresses are of something at least two bytes aligned, so the low bit is
free on every target, 32-bit included. Thrown<E> is E and this word;
Site<E> is this word alone; Raised is its box and this word.
Result always finds its niche there.It is a NonNull, so the niche no longer depends on the author’s type: a unit
struct, a field-less enum and a struct of one Int all fit a Result<Int, _>
in two words, and an Option<Site<E>> is one. This is what makes §2’s bold
column hold for any E a program writes, and it is asserted in
error.rs’s tests, so a change that loses it fails there rather than in a
profile.
The mixed channel (ADR-157) is the language below’s
Box<dyn Error>, sixteen bytes in any Result, so its success path was never
the problem; what shrinks is the envelope inside the box, from 104 bytes to 24.
It is still two allocations per throw — the envelope, and the author’s
error behind its own Box<dyn Error> — and one was discussed. Putting the error
inside the envelope makes the envelope generic (Raised<E>), and then nothing
can find it again: joining and error.full() reach it by a downcast, and a
downcast names one concrete type. The two ways around that both cost more than
the allocation: a failure channel type of this library’s own, which every ?
from a Rust-side error, every described crate and std’s own Rust half would
convert into; or recognising an envelope through the deprecated
Error::description. The allocation is on the failure path only and is left.
An error with no fields is zero-sized, so its box allocates nothing and it is
one allocation already.
A &'static str is two words; the address of one is one. The emitter writes
raise(error, &"load") and throwing(error, &"load") where it wrote "load",
and the language below promotes &"load" to a 'static as it does any
constant, so it is still text written into the binary, read only when the long
form prints. nikaia_std::error::Origin names the type.
overlap whose branches fail together and a failing cleanup
get there.unsafe blocks read the word back (the origin, the cold box) and one
frees the box. They are in one type of about eighty lines, each with its
argument, and the module’s tests run clean under Miri with strict provenance:
both states, a tree of joined failures dropped exactly once, split and
refill handing the word across, and an envelope crossing a thread.All of it, in 0.0.187: Tail, Cold and Origin in
crates/nikaia-std/src/error.rs, with Thrown<E>, Site<E> and Raised on
them and nothing public changed but the origin’s type; the emitter’s two
throw lowerings; and eight tests beside the envelope’s own — the sizes of §2,
the tagged word, the move to the cold box on joining, the trace in it, the word
through split and refill in both states, the boxed channel joining, the
drop count, and Send/Sync.