T? Is How It Says SoStatus: Superseded by ADR-302 — its D1-D5 are D10-D14 there
Date: September 19, 2026
Target Version: Nikaia 0.0.32
Related: ADR-147 D3 and D4 (the opaque handle and the CStr,
and the question this answers), ADR-293 (an index out of range
aborts), Part I 2.3 (T?), Part III 15.1, Part III A.2
ADR-147 D3 writes its own example as an out-parameter:
extern "C" {
opaque type sqlite3 released by sqlite3_close
fn sqlite3_open(path: &[u8], db: &mut sqlite3) -> i32
}
and sqlite3_open(path, db) needs db to exist before the call. C writes
it as an uninitialised pointer; this language has no uninitialised binding, and
D3 gives a handle no constructor — deliberately, because an address somebody
chose is the one thing a handle may never be. So the record’s own example is not
a program, and NK1160 says so.
The same hole is on the other side. fopen returns NULL where it cannot open
the file, getenv returns NULL where the name is not set, and most of C’s
handle-returning surface does. Nothing in a declaration could say it, so
CStr::to_string had to report a null address as a failure — which conflates
there is no text with the bytes are not UTF-8, two different things a caller
answers differently.
One shape answers both, and the language already has it.
T?, and that is all.extern "C" {
opaque type sqlite3 released by sqlite3_close
fn sqlite3_open(path: &[u8], db: &mut sqlite3?) -> i32
fn sqlite3_errmsg(db: sqlite3) -> CStr?
}
Part I 2.3’s nullable, on a handle, meaning what it means everywhere else: a
type is non-nullable unless it says otherwise, ?? and ?. are how a program
gets past it, and NK1121 and NK1125 are what a program that forgets meets.
No second spelling and no rule of its own — what is new is only that a handle
may now stand where a T? stands.
null is the address, so a nullable handle costs nothing.A handle holds a non-null address, and T? is the absence of one. Those are
the same two states C spells with a pointer and NULL, so the lowering is the
pointer either way: a nullable handle is one machine word, and &mut sqlite3?
is sqlite3 ** exactly as C writes it.
That is what makes the out-parameter work rather than merely typecheck. A shape the language chose freely would be the right words around the wrong memory, and the boundary is the one place where the two cannot differ.
? is a claim, and it is checked.fn fopen(path: &[u8], mode: &[u8]) -> FILE says this never hands back
nothing. C may still do it, and what the program gets then is an abort
naming the function and this language’s words for what happened
(Part III A.2) — never a handle that is secretly
null.
The claim is the author’s and the check is the compiler’s, which is the
arrangement sync on a declaration already has (ADR-124 D2). A
declaration is written from a header by somebody reading it, and reading is
where this can go wrong; one branch on a call that is crossing into C anyway is
what that costs.
CStr is nullable like any other handle, and to_string stopsgetenv is declared -> CStr? and the program writes ?? "" or ?.. The copy
then fails in one way rather than two — the bytes are not UTF-8, which is
what fs::read_to_string already fails on — and there is no text stopped
being a failure and became a value.
? on a view belongs to what it points at.&mut sqlite3? is a slot that holds a handle or nothing, not a view that
may be absent. A view at the C boundary lives for the call
(ADR-147 D1) and is never absent, so the other reading is a shape
nothing writes — and the out-parameter is what every C library with one means.
It is the one place Part I 2.3’s own reading is turned around, and it is
turned around only for &mut T and &[T]. A plain &T? is untouched:
let mut m: &str? = null is 2.3’s own example and stays a nullable view.
fopen,
getenv, dlopen, sqlite3_errmsg all say what they do.NULL stops being a word this language has no answer for.std entry that fails in
one way rather than two, and D5’s reading in both directions of the ledger’s
type text.&mut [u8] and
is the caller’s memory rather than a handle.null. ?? and ?. are how a T?
is read (Part I 2.3) and no program here has asked for an equality against
the absence. x == null lowers to x == None today, which wants the type to
compare and a handle does not — so it is a shape that does not compile rather
than one that is wrong, and it waits for a program.&mut i32 is D1’s already;
what a &mut Vec[i64] at the boundary would mean is not asked by any
program.All four. Step 4 is ADR-147 §5’s own step 5 —
examples/sqlite/main.nika, which is what this record was in the way of.
The hull under a handle is NonNull, and that is the whole of D2. Rust lays
Option<T> over the same machine word for a type shaped like this, so a
nullable handle costs nothing and &mut sqlite3? comes out *mut
Option<sqlite3> — one pointer to one pointer, which is sqlite3 **. The out-
parameter works rather than merely typechecking, which at this boundary is the
only kind of working there is.
It forced the declaration’s result to change, and that is D3’s real cost.
A handle cannot be the declared return type any more: the hull is non-null, so a
NULL arriving in one is undefined before any check could run. So a
declaration hands back the address C returned and the hull goes on at the
call, where from_c checks the claim or maybe reads NULL as None.
D5 was found by building rather than by writing. &mut Block? parsed, and
the ? went to the view — Part I 2.3’s own reading, and the wrong one here:
what C wants is a slot that holds a handle or nothing. A view at this boundary
lives for the call and is never absent, so the other reading is a shape nothing
writes. It is turned around for &mut T and &[T] only, in Ty::from_ast and
in the ledger’s text in both directions; a plain &T? is untouched.
CStr::to_string lost a failure. It reported a null address and bad UTF-8
as one throws, which conflated there is no text with the bytes are not
text. The first is a value now — a declaration says -> CStr? and the
program writes ?? — and what is left is the failure
fs::read_to_string already has.
Six more tests in crates/nikaia/tests/foreign_pointers.rs, twenty-nine in
the file, one of which runs getenv both ways: HOME is set and
NIKAIA_NOT_SET is not, so finding nothing is a value the program reads with
?? rather than a failure it has to catch.
And step 4 found that D1 is not enough on its own.
sqlite3_prepare_v2 takes the database the out-parameter filled, and a
sqlite3? cannot be handed to a parameter that takes a sqlite3: ?? wants a
fallback and there is no second handle, ?. reaches a member and this is a free
call. The answer was already in the language — slot ?? throw Refused::NoDatabase
narrows a T? to a T, which is ADR-276 D20’s jump-as-an-expression
meeting ADR-279’s ?? — but nothing had written the two together,
and no shorter program would have.