ADR-081: A + Over Text Is a Call, and an Expression Gets a Span to Key It By

Status: Accepted Date: September 15, 2026 Related: ADR-285 D1 (the trait that made one index rule work everywhere — this is that arrangement a third time), ADR-285 D5 (the overflow abort, which is what keeps arithmetic out of it), ADR-288 (the channel the checker hands answers over, which this gives its first expression-level key), ADR-296 D17 (the emitter knows no types)

1. Context

Lowered as written, three of the four ways a program can join text are refused by the language below. With s and s2 being String:

written what the language below said
"a" + "b" error[E0369]: cannot add &str to &str
"a" + s error[E0369]: cannot add String to &str
s + "b" compiled
s + s2 error[E0308]: mismatched types

Rust’s own rule — String + &str and nothing else — would reach the user unchanged, about a file nobody wrote, which is Part III C.1’s class. This language’s + says nothing about which side is owned and should not have to.

And a checker that types a concatenation as a view is worse. If "User: " + self.username came to a view, a function declaring -> String would be refused as NK1104 — a false refusal, and Part III C.4 says this compiler never refuses a correct program. Part I 4.7 writes that line as its own example.

2. Decisions

D1 — Expr::Binary carries a span, and it is the operator’s.

The first expression-level span in this AST, because an answer has to be keyed by which + rather than by the statement around it — a + b + c is two of them, and every other answer in ADR-288’s channel is keyed by a statement because every other answer is about one thing per statement.

The tail’s span — the operator and what stands to its right — rather than the whole expression’s, because that is what the grammar has in hand where the node is built, and uniqueness is the only property a key needs.

One variant and not every expression, because one variant needed it. A field nobody reads is a field that goes stale, and the next one that needs a span will know where to put it.

D2 — A + where either side is text lowers to nikaia_std::concat::plus, and its result is a String.

A trait with an impl per shape, and the language below picks — which is ADR-285 D1’s own sentence a third time: this emitter does not know types, and a rule applied everywhere cannot be applied to the wrong thing.

What that buys is not merely working. Each shape keeps the lowering that suits it. The obvious single rule — lower every concatenation to a format! — measured six times slower on the one form Rust already accepts (250 ms against 42 ms for three million concatenations); the trait costs that form nothing. The rule is the table of four cases, written once in std instead of four times in the emitter.

And the result is a String. That is a decision and not an inference: a concatenation makes a new value, so there is nothing else it could be, and saying it is a view is what would make -> String a false refusal.

D3 — A number’s + does not move, and that is what makes D2 narrow.

A trait over + would take numbers too, and a number’s + may not leave this language’s own crates. ADR-285 D5 turns overflow-checks on per Nikaia crate while the profile turns them off, because a foreign crate’s hash function wraps on purpose — and nikaia_std is on the foreign side of that split. Measured, with the same profile shape:

a + b in the checked crate                    → aborts
the same a + b through an #[inline] helper    → -9223372036854775808, silently
in the unchecked crate

Inlining does not carry the check across: it is decided where the code is written. So any arithmetic moved into a std helper silently loses the abort this language promises. index::at escapes it only because its conversion is an explicit try_from, and index::set because it does no arithmetic at all. That is a trap for the whole family of these decisions and it is written down here rather than left to be met again.

3. Consequences

Part I 4.7’s "User: " + self.username is accepted by the checker, and Part I 6’s println(prefix + "System started") compiles.

All four shapes are programs, and the one Rust accepts directly keeps its speed and what it allocates.

The channel has an expression-level key, which the next answer about a sub-expression can use instead of inventing a positional one.

4. What this does not decide

This site is open source. Improve this page.