ADR-068: A Value Whose Type Is Not Known Is Converted Into the Nullable Slot, Not Left Alone

Status: Superseded by ADR-278 — its D1-D3 are D7, D6 and D8 there Extends: ADR-052 D4, which decided that the compiler writes the wrap and the checker says where — and left one answer out Related: ADR-028 (the emitter has no types, so the question is the checker’s), ADR-020 and ADR-028 D5 (the ledger is thin on purpose, which is why “the type is not known” is a permanent state and not a temporary one), ADR-010 D1 (an analysis that fails open is a vulnerability generator — the caution this keeps while removing its cost)

1. Context

ADR-052 D4 gives the Some(…) to the compiler: a plain T standing where a T? is wanted is Part I 2.3’s one widening, and the constructor is not a form Nikaia has. Four positions carry it — an annotated let, an assignment, a return against a declared result, and a struct literal’s field — and a call’s argument is the same rule written a fifth way.

The rule writes it only where the value is known not to be a T? already, because wrapping one that is would make an Option<Option<T>>. That caution is right. What it did with the rest was stay silent, and this is what that costs:

impl U {
    fn a(&self) -> String? { return self.name.clone() }   // no wrap
    fn b(&self) -> String? { return "lit".to_string() }   // Some(…)
}

fn c(u: &U) -> String? { return u.name.clone() }          // no wrap
fn d() -> String? { return "lit".to_string() }            // Some(…)

Checked::nullable_sites held two of those four. What separates them is .clone(): no ledger describes it, so the value’s type is ? and the rule declines. A reader then gets rustc about the generated file — “expected enum Option<String>, found struct String“, with “try wrapping the expression in Some” about a form this language does not have, which Part III C.1 calls a bug in this compiler.

And it was first filed under the wrong cause. The entry read “a return in an impl method does not get its Some(…)” — the position, which is not it: b is in an impl and is wrapped, c is free and is not. Measuring the four shapes is what replaced a plausible cause with the real one.

Why this does not go away by itself. “The type is not known” is not a gap waiting to be filled in. The ledger is thin on purpose: an entry exists because a program asked for it (ADR-028 D5), and a language that lowers onto another one’s library will always have names nothing has written down yet. So the silent position is permanent, and so is the class of programs it refuses.

2. Decisions

D1 — Where the type is not known, the wrap is value.into().

self.name.clone().into()        // the type is not known
Some("lit".to_string())         // the type is known

It is correct in both directions, which is the whole of the argument. Rust has impl<T> From<T> for Option<T> and the blanket identity impl<T> From<T> for T, so x.into() against a declared Option<T> gives Some(x) where x is a T and x itself where x is already an Option<T>. The question the checker could not answer therefore stops needing an answer — not by being guessed at, but by being made irrelevant.

The target is pinned in every position it is written. A declared result, an annotation, an assignment’s left side, a field’s declared type, a parameter’s — all five name the type the conversion resolves against, so it is never inferred from the value alone.

D2 — Where the type is known, the constructor stays.

Some(…) is what the generated Rust goes on saying wherever the compiler knows enough to say it. The conversion is what uncertainty costs, not what every program pays — which matters because ADR-004 D2 asks that the text a person reads be the text that is compiled, and Some(x) says what the source means where x.into() only does what it means.

A literal counts as known, unchanged: a number has no type of its own on purpose (Part I 2.4), so return 42 against a declared i64? answers ? and the type alone would leave the commonest case in the section unwrapped.

D3 — The four positions ask one function, and it answers how rather than whether.

The rule was four copies of the same three conditions, and the copies had already drifted in what they did with an unknown type. It is one function now (check::wrap_for), and what it hands back is a Wrap — Constructor, Conversion, or nothing at all for a value that is already a T? and needs neither.

The emitter asks one function in turn for what goes before a wrapped value and what goes after, so the two forms are written in one place rather than five.

3. Consequences

Programs that did not compile now compile, and none that compiled stops. The sites the checker was already sure about are byte-identical; the change is entirely in the positions that used to produce nothing.

The .into() is visible in the generated Rust, and that is the price. A reader comparing the two languages sees a conversion where the Nikaia says nothing — the same pressure on ADR-004 D2 that ADR-055 §2 D6 names for async fn, and smaller: it stands only where the compiler could not work the type out, and it is honest about that.

It makes the ledger’s thinness cost less rather than hiding it. A clone nobody has written down is still a type this checker cannot see, and --trust and the contracts work are still what improve that. What changes is that the consequence is no longer a program that does not build.

4. What this does not decide

5. What is built

All three. check::Wrap and check::wrap_for are D1 and D3; the four positions record which of the two they need, and Emitter::around writes it.

tests/nullable.rs runs the decisive program rather than reading it: one file whose two methods both have values this checker cannot type, where one is a plain String and the other is already an Option<&str> — .clone() and strip_prefix, neither in any ledger. .into() is the wrap for the first and the identity for the second, and the program prints Ada, da and no prefix. Neither method compiled before, and Some(…) would have been wrong for the second.

A second test holds D2 from the other side: a known type still emits Some("lit".to_string()), in an impl and in a free function alike, so the conversion stays the exception.

This site is open source. Improve this page.