ADR-211: What an Error Carries Besides Itself Is One Word — the Special Cases Pay for Themselves

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)

1. Context

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.

2. Measured

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.

3. Decisions

D1 — The site, the trace and the list are one word, tagged.

nikaia_std::error::Tail, private to the module:

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.

D2 — The word is never zero, so a 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.

D3 — The boxed channel’s envelope is three words, and stays behind its own box.

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.

D4 — The origin is a reference to the text, and the emitter writes it so.

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.

4. What it costs

5. What is built

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.

This site is open source. Improve this page.