ADR-024: The Type Checker Says Only What Is Written Down

Status: Accepted Related: ADR-020 (the ledger this reads), ADR-300 (the spans D7 waits on), ADR-014 (every module it moves makes this checker stronger without changing it), ADR-296 D17 (name for name, applied to types)


1. Context

Nikaia has checked one of its own rules since ADR-020: Part II 12.1, that a sync function may only call sync functions. Types it did not check. They were checked by rustc, after the lowering, in Rust’s vocabulary, about a file the programmer did not write.

ADR-300 already places those messages back on the .nika line they came from, and the placement is exact — the lowering is name for name, so only the position was ever wrong. But the text is still Rust’s. A .nika file that puts a literal in a String field is told about expected String, found &str`` by a compiler the programmer did not invoke, and Part III C.1’s Iron Rule (“an untranslated backend error reaching the user is a Nikaia compiler bug”) says that is a bug. The NK1xxx range — Syntax & types — was the only one in the catalogue with no entries at all.

The thing standing in the way was never the algorithm. It was that Stage 0 cannot see most of the types involved. Half of std is still Rust (ADR-014), and a Nikaia program calls push_str, entry, map_or, chars and nth freely. A checker that had to have an opinion about those would need either a second frontend that reads Rust, or a guess.

A type checker that guesses is worse than none. It reports errors that are not there, and the first one teaches the person reading it that the tool is to be worked around rather than believed.


2. Decisions

D1 — Unknown is part of the type language, and it is not a type.

contracts::ty::Ty is ast::Type with one addition and one subtraction. The subtraction is the interner: a ledger is a file, so a type is text, and two types are the same when they are written the same — name-for-name (ADR-296 D17) applied to types.

The addition is Unknown, written ?, which is the absence of a claim. It is what the compiler records where it has nothing to say: the result of a method on a receiver it cannot resolve, of ?, of ??, of an index, of a DSL block, of a generic parameter, of a function whose ledger entry has no signature.

D2 — An error is reported only where both sides are known and disagree.

Unknown is compatible with everything, in both directions. Every rule in the checker has the same shape: infer both sides, compare, and say nothing unless both are written down.

The consequence is the promise the tool is worth running for:

It never rejects a program that is correct.

It does not catch every program that is wrong, and that is stated rather than implied. What it catches is a function of how much is written down — a number that goes up as ADR-014 proceeds, without the checker changing.

D3 — The ledger is the checker’s database. There is no second one.

Changed by ADR-251 D4: the ledger spells a signature as Nikaia source does — ref self, ref T, [T](x: T) — not in the & spelling below.

Part III 13.5’s ledger already recorded what a caller must know about a body it cannot see: sync, throws, and what a result may point into. It gains two keys:

[fn."Stats::add"]
sync = true
signature = "(&Stats, temp: i32)"

[type."Reading"]
pub = true
fields = ["name: &str", "temp: i32"]

One mechanism, three sources, in the order a call resolves: this unit’s own contracts, inferred from the source being compiled; std’s, shipped in std.contracts; and a package’s, which Part III 13.5 already says a package ships. Nothing about the checker changes when the third arrives.

A signature is one key rather than two because a person reads a function as one fact, and because a method’s receiver belongs in it: (&Stats, temp: i32) puts self first, so a caller reads the arguments off the same list either way.

D4 — A type parameter is never claimed as a type: it is declared, bound at the call, and ? where nothing binds it.

Changed by ADR-295 D1-D2 and ADR-251 D4: the parameter is no longer erased; it is written in the ledger and bound at each call. What this decision protected - no false “i32 is not T” - holds by binding instead of by erasure.

T is a name that stands for a type rather than being one, and the checker never reports a value as “not T”. The ledger writes the parameter as the source does, fn first[T](xs: T) -> T as [T](xs: T) -> T. At a call, T is a variable bound from the arguments, so first(1) is checked as an i32; a variable nothing binds is ? and fits anything, so a gap in the binding is never a refusal. Self in a trait’s method stands for a type no call site binds, so there it stays ?; on an impl’s own methods the ledger writes the type the impl is on.

D5 — A string literal is a &str, and this is a divergence from Part I 2.4.

Changed by ADR-282 D3: a text literal is a String wherever the use asks for one (an annotated let, a field, an argument, a return, beside other String text), and a view everywhere else. Changed by ADR-309 D2: an f"…" literal is always a String; a plain "…" is a view unless its use asks for a String.

A literal with nothing asking for a String is a view of static text, and the checker says so.

Part I 2.4 says the opposite in a passing remark — let name = "Nikaia" // Compiler knows this is a String — and 2.3 writes let strictly_string: String = "Hello". Part I 6.6 says something incompatible with both, and says it about the language’s central performance promise:

Owned — a String of its own — one allocation, only where you wrote .to_owned().

A literal that is a String allocates at every mention, in a language whose whole claim about large inputs is that it does not. The two spec passages are a remark inside a type-inference example; the third is the reason 1brc.nika runs in the time it does. The literal is a view. Part I 2.3 and 2.4 are corrected here, and a String is made where somebody wrote that they wanted one — which is ADR-013 D7’s rule, applied to the most common conversion there is.

D6 — A bare number makes no claim.

3 is Unknown, not i32. Committing a literal to one numeric type would make add(3) an error wherever the parameter is not that one — a false positive on the most ordinary line there is. This is what the language below does too, and it is the one place the checker is deliberately less precise than it could be.

D7 — A finding names the statement, not the expression.

Expression-level spans are open work in the parser, and the sync check has reported at statement granularity since ADR-020 for the same reason. A caret on the right line is worth having; the day expressions carry spans, both checks get narrower without either changing.

D8 — A grammar’s action answers to its rule’s return type.

rule MEASUREMENT -> Reading = … -> { Reading { name, temp } } declares what it builds, and the action is where a struct literal is most often typed out in full — so it is where a mistyped field name most often is. What a pattern binds has no type here: that belongs to the parser backend and Stage 0 does not read it, so every binding is ?. The rule’s return type is written down, and that is enough for both checks that matter in an action — the shape it builds, and the names of the fields it fills.

D9 — In the hand-written half of a ledger, a bound is recorded ?.

A parameter whose Rust type is a trait bound rather than a type accepts several types, and the ledger must say so. fs::write(path: impl AsRef<Path>, data: impl AsRef<[u8]>) takes a String, a &str and a buffer; recording data: &str would make the checker refuse fs::write(path, text) for a String — a correct program, rejected, which is the one thing D2 promises cannot happen.

So the rule for every entry written by hand: a concrete Rust type is recorded, a bound is recorded ?. The arity survives either way, and the arity is the half that catches the most. std.contracts says this where the entries are, because it is the mistake a reviewer of that file has to look for.

D10 — The corpus is the guard.

crates/nikaia/tests/typecheck.rs runs the checker over every .nika file in the repository — the eight runnable examples, fortunes.nika, and std’s own modules — and fails if any of them produces a finding. Beside it are the deliberately-wrong programs, one per code, which are what makes a clean corpus evidence rather than a tautology: without them, a checker that had quietly stopped working would pass.


3. Consequences

The NK1xxx family exists. Nine codes, each with the reproduction Part III C.3 requires:

Code What it says
NK1101 a call passes the wrong number of arguments
NK1102 an argument is not what the parameter takes
NK1103 a let says one type and is given another
NK1104 a return, or a body’s last expression, is not what was declared
NK1105 an assignment is not what the target holds
NK1106 a struct literal gives a field the wrong type
NK1107 a field that is not there
NK1108 a condition that is not a bool
NK1109 a call names an option the callee does not have (Part I, 5.1)

Every one of them names a concrete way out, as Part III C.2 requires: &str against String suggests .to_string(), two numbers suggest as, and a field that is not there is matched against the ones that are — with a distance that counts a swapped pair as one edit, because nmae for name is the typo this is for and plain Levenshtein charges two for it.

The case the checker exists for is a program the backend has not compiled, checked in the language it was written in.

std.contracts carries signatures in both of its halves — inferred for the Nikaia modules and regenerated by a test that fails on drift, written by hand and reviewed like code for the Rust ones. The hand-written share shrinks as ADR-014 proceeds.

Where a type does travel, it travels. A method on a known receiver is looked up under the Type::method name the ledger records, matching the module prefix as a suffix — name-for-name resolution as everywhere else. A for over a Vec[Row] binds a Row; a constructor hands back the type it is on.

What it does not catch. A method on a receiver whose type is not written down. The element type of anything but a list, and of a list iterated as pairs. What a ? or a ?? unwraps. What a match arm binds. What a grammar pattern binds. Each of these is Unknown because a signature is missing, and each becomes checkable when one is written — which is the property D3 was chosen for.


4. What was rejected

This site is open source. Improve this page.