ADR-158: std Names What It Throws, and It Is One Type

Status: Superseded by ADR-280 — its D1-D4 are D5-D8 there Date: September 20, 2026 Target Version: Nikaia 0.0.75 Related: ADR-023 D1 (the set), D3 (payload), D4 (the two axes and the named residue), D6 (IoError::NotFound in its worked output), D10 (the payload as a view), ADR-024 D1 (?, the absence of a claim), ADR-154 D3 (a type lives in its module), ADR-157 (the channel), Part I 1.3, Part I 7.1, Part III 17.1, Part III A.1

1. Context

ADR-023 D1 records throws as a set of error types, derived over the call graph, and ADR-157 made that set decide the failure channel: where it names one type, a catch matches on that type’s variants.

For std the set said nothing. Every entry wrote throws = ["?"] — ADR-024 D1’s ?, the absence of a claim — because these functions fail with the language below’s errors and no Nikaia type stood for one. The ledger’s own header said so plainly, and called it honest, which it was. What it cost is that the column added nothing for the one library every program uses: a caller read ["?"] as it fails, which is exactly what the boolean before the column said.

It also put a floor under everything above it. A program that reads a file has "?" in its set for ever, so ADR-157’s channel cannot be named, ADR-101’s an error that newly reaches a catch is named once has no set to diff, and NK2401 has nothing to check. Three records waiting on one missing name.

The measurement is what made this small. Of 120 fn entries in std’s ledger, nine throw. Two of them already name their failure (Overtaken, on the lock’s two set(…; after:) doors) and work. Of the seven left:

entry what it actually fails with
fs::map, fs::read, fs::read_to_string, fs::write std::io::Error
io::read, io::read_to_string std::io::Error
foreign::CStr::to_string a boxed &str, and its own doc calls it the same failure fs::read_to_string has

So it is one type, not a surface to design. And the specification had already named it: ADR-023 D6’s worked output writes IoError::NotFound, Part III 13.5’s example row writes throws = ["ConfigError", "IoError"], and Part III A.1 calls a missing file the example of a recoverable error.

2. Decisions

D1 — io::IoError is what std throws, and every entry says so.

An enum with payload and an impl Error, like any error type Part I 7.1 describes. The seven entries above name it; the lock’s two keep Overtaken. No entry in std writes ["?"] any more, and a test holds that, because the easiest way to lose this is a new entry written the old way.

D2 — It lives in io, and a program that matches on it writes it out.

use std::io and io::IoError::NotFound(p). That is ADR-154 D3’s rule applied straight: what needs no prefix is Part I 1.3’s list, and this is not on it — a name joins that list by a record (D4 of ADR-154), and an error type is common is the argument that rule exists to refuse.

The cost is named and accepted. fs::read throws an io type, so a program that takes the failure apart writes a second use. A program that only passes it on writes nothing at all, which is D8 of ADR-023: propagation is what a call does. This is the same trade ADR-156 D1 decided the other way for Bytes, and the difference is which half of the program meets the name: fs::read’s result is written down by anyone who holds it, its failure only by someone who takes it apart.

D3 — Four variants, and the list is short because it is closed.

enum IoError {
    NotFound(String),
    PermissionDenied(String),
    NotText(String),
    Other(String),
}

ADR-023 D4 makes the variants of one error type closed — a match is exhaustive, and adding one is a breaking change, correctly — so each of these is a lasting commitment and each is here for a reason that can be cited:

Everything std::io::ErrorKind distinguishes and this does not is Other. That is the direction to be wrong in: a variant added later breaks an exhaustive match, and one that was never needed cannot be taken back.

D4 — The payload is what the failure was about, and it is owned text.

An operating system’s error does not carry the path, and not found without the thing that was not found is the round trip to the user ADR-023 D3 names. So std attaches it at the call that knew it.

Owned and not a view, which is the one place this record knowingly falls short of ADR-023 D10: a path that came from a buffer travels in the error as a view. That is the tether (ADR-008), which is not built, and a lifetime on this type would reach every std signature. One allocation on a path that is already failing is the honest price until then.

3. Consequences

A program that reads a file has a set that says something. throws = ["io::IoError"] where it read ["?"], and a program that also throws its own gets both — which is the one thing a set does that a boolean cannot.

No lowering changes. ADR-157 D1 names a channel only after a type this unit declares, and a program does not declare io::IoError, so every such function keeps the box exactly as before. The claim moves first and the representation follows, which is the order ADR-008’s analysis was built in for the same reason.

Three records stop waiting on a name. ADR-157’s channel can be extended to a type a ledger describes; ADR-101’s diff has a set to diff; NK2401 has something to check. None of them is built here.

as_text names what it read. task::as_text finishes one half of a read_both exactly as read_to_string would have, which is its whole reason to exist — and since D4 that includes saying which file, so it takes the path.

4. What this does not decide

5. What is built

This site is open source. Improve this page.