Skip to main content

MEP 16. Null Safety

FieldValue
MEP16
TitleNull Safety
AuthorMochi core
StatusDraft
TypeStandards Track
Created2026-05-12

A note on names

This MEP renames the empty-case literal from null to none. The reasoning is small but worth stating up front. Option<T> is an algebraic data type with two constructors, Some(T) and None. There is no pointer in the model and no "null reference" anywhere. Calling the empty case null carries baggage from C/Java/SQL that does not fit, and nil from Go/Ruby still reads pointer-ish. none is the name from Haskell, Rust, OCaml, Scala, F#, and Swift, and it matches what the value actually is: the None constructor.

So everywhere this document says none, read it as "the literal of Option<any>, the value that lives at the empty side of the option". Old code that uses null keeps working through a parser alias during stage 1, then null is removed in stage 5.

Abstract

Mochi commits to total option safety. No Mochi program type-checks if it could observe a none at a use site that expects a T. The plumbing is already mostly there: OptionType{Elem: T} is wired end to end, T? parses (MEP-10 C1), and none : Option<any> is closed (MEP-10 A2). What this MEP adds is the access discipline. Three operators (?., ?[ ], ??), flow narrowing on == none / != none, and one deliberate omission: no force-unwrap operator. The result is that every read of a T? is either statically proven to hold a T, or it goes through an operator that returns a T? or takes a fallback. Nothing in the type system can produce a panic.

The MEP also closes the part of MEP-14 that was waiting on this work. The right-hand binding of a left join, both sides of an outer join, and missing map keys all become Option<T> instead of producing a runtime nil. Those changes break some fixtures; the doc lays out the staging.

Motivation

Tony Hoare called null his billion dollar mistake at QCon in 2009. The point of the talk was not that null is wrong in some abstract sense, but that giving every reference type a free null inhabitant was a default he could not justify. Everyone who shipped a retrofit since (Kotlin, Swift, Dart, TypeScript strict, C# 8+) reports the same thing: most of the bugs the retrofit catches were never going to be caught by tests, because the tests ran on the happy path where the value was always present.

Mochi has the easy version of the problem. The codebase is young. The type checker already has the right shape (OptionType{Elem: T}). The pieces that are missing are the access rules. Today these programs type-check and then crash:

let m: map<string, int> = {"a": 1}
print(m["missing"] + 1) // runtime nil + int

let xs: list<int> = [1, 2]
left join y in [] on x == y
select y * 2 // unmatched y -> nil -> nil multiply

Some of these the runtime catches with a panic; others coerce silently and print none. None of them should leave the checker.

There is a second force. MEP-14 (Query Algebra) left a deferred item: the right side of a left join should be Option<T>, but today it is typed T and bound none at runtime. The type system is lying. Closing that item needs the option discipline to exist first, which is what this MEP is for.

Status at a glance

ItemStatus
T? syntax parses (MEP-10 C1)closed
none : Option<any> (MEP-10 A2)closed
Option<S> <: Option<T> when S <: Tclosed
Assignment widening T -> T? (auto-wrap)open
T? rejected in T slot without narrowingopen
Unguarded field access on T? raises T058open
Unguarded index access on T? raises T058open
== / != against none is bool for any T?open
Safe-call operator ?.open
Safe-index operator ?[ ]open
Coalesce operator ??open
Flow narrowing on if x == none { ... } else { ... }open
Flow narrowing on if x != none { ... } else { ... }open
Flow narrowing on x != none && ... / x == none || ...open
match x { none => ..., _ => /* x : T */ } narrowingopen
map<K, V> index returns V?open
left join right side becomes Option<T> (MEP-14)open
right join left side becomes Option<T> (MEP-14)open
outer join both sides become Option<T> (MEP-14)open
No force-unwrap operator (!!, ?!, etc.)by design

Specification

Surface

T? is sugar for Option<T>. Already wired in the parser (TypeRef.Optional) and the type checker (resolveTypeRef builds OptionType{Elem: T}).

none is the sole literal of Option<any>. It is added as a keyword. During stage 1, null parses as an alias for none; the alias is removed in stage 5.

Three operators are added to the parser:

  • a?.f reads as "if a holds a value, take field f, otherwise none".
  • a?[k] reads as "if a holds a value, look up k, otherwise none". Works on list? and map?.
  • a ?? b reads as "if a holds a value, take it, otherwise evaluate b".

Optional method call is a?.f(args), which is the same ?. rule on the call form. There is no !! or ?! operator. See Rationale.

Type rules

Let A be T and B be T? (that is, OptionType{T}).

R1. Auto-wrap on assignment

A is assignable to B. let x: T? = expr_of_T works without an explicit wrap.

let n: int = 7
let m: int? = n // OK; auto-wrap T -> T?
let p: int? = none // OK; none : Option<any>, widens to Option<int>

R2. No implicit unwrap

B is not assignable to A. let y: int = some_int_opt is T008 unless some_int_opt is narrowed.

R3. Equality with none

x == none and x != none are valid for any x : T?. The result is bool. The literal none may appear on either side. The comparison is at the option layer; it never inspects the wrapped value.

For x : T (non-option), x == none is a type error. The comparison can never be true and silently returning false would hide the bug.

R4. Dereference requires a guard

For x : T?, the expressions x.f, x[k], and x(args) are rejected with T058. The fixes are:

  • Narrow: if x != none { x.f } (the body sees x : T).
  • Safe operator: x?.f, x?[k], or for calls x?.invoke(args) (the latter is deferred, see Open Questions).
  • Pattern match: match x { none => default, _ => x.f }.
  • Coalesce: (x ?? default_T_value).f.

R5. ?. typing

For a : T? and T.f : U, the expression a?.f has type U?. Chaining is left-associative: a?.b?.c parses as (a?.b)?.c. The intermediate types are B?, then C?. If a chain step is on a non-option, that step uses plain . and never ?..

For method calls, a?.f(args) requires T.f callable and applies the same U? widening.

R6. ?[ ] typing

For a : list<T>? and an int index i, a?[i] has type T?. For a : map<K, V>? and key k : K, a?[k] has type V? (which the m[k] rule below would also produce on a non-option a).

R7. ?? typing

For a : T? and b : T, a ?? b has type T. For a : T? and b : U?, a ?? b has type (T | U)? reduced via subsumption (if T = U the result is T?; if U <: T the result is T?).

The RHS may have side effects; it is evaluated lazily, only when the LHS is none.

R8. Map index returns V?

For m : map<K, V>, m[k] has type V?. The key may be absent; the type system enforces guarded access. The current behaviour (V with runtime nil on miss) is gone.

A literal-key access where the key is statically present (e.g. let m = {"a": 1}; m["a"]) still returns V?. We do not special-case literal keys; the rule is simpler when it is uniform, and the rewrite is one ?? away.

R9. List index stays T

For xs : list<T> and i : int, xs[i] returns T. Out-of-bounds is a runtime panic by design (lists are dense; indexed access is meant to be unchecked). Guarded access uses xs?[i] on a list<T>?, or if i < len(xs) { xs[i] }, which a future bounds-narrowing MEP can prove statically.

The asymmetry with maps comes from the data model. Maps are sparse; key-absent is normal data. Lists are dense; index-out-of-range is a bug.

R10. Join binding retyping (MEP-14 dependency)

  • left join r in xs on cond: inside on, r : T. After the join, r : T?.
  • right join l in xs on cond: inside on, l : T. After the join, the left side becomes T? and l : T.
  • outer join r in xs on cond: inside on, r : T. After the join, both sides become T?.

The on clause sees candidate matches with both sides present. Later clauses (where, select, having, more joins) see the post-join types.

Flow narrowing

Narrowing is what makes if x != none { x.f } type-check without ?.. It works on the binding x itself, not on subexpressions. if x.y != none { ... } does not narrow x.y because x.y is not a binding. (Stable expression paths may come later; see Open Questions.)

The rules apply to local bindings only: let, var, function parameters, query iteration variables, match arm captures. Closure-captured bindings are not narrowed across the closure boundary, because a captured var can be mutated by the outer scope before the closure runs.

N1. if-condition narrowing

For x : T?:

  • if x == none { /* x : T? */ } else { /* x : T */ }
  • if x != none { /* x : T */ } else { /* x : T? */ }

none == x and none != x narrow identically.

N2. && / || short-circuit narrowing

For x : T?:

  • x != none && e: inside e, x : T.
  • x == none || e: inside e, x : T.
  • x == none && e: inside e, x : T? (unchanged; x is known none but we keep the option type).
  • x != none || e: inside e, x : T? (unchanged).

N3. match narrowing

For match x { none => arm1, _ => arm2 } with x : T?:

  • In arm1, x : Option<any> (the matched value is the none literal).
  • In arm2, x : T.

Variant patterns (Some(v) => ...) are deferred (Open Questions); the none literal plus wildcard covers all cases at the checker level.

N4. Reassignment invalidates narrowing

If x is reassigned to a T? value inside the narrowed scope, narrowing is dropped from the assignment site onward.

if x != none {
print(x.f) // x : T, OK
x = maybe_none() // x : T?
print(x.f) // T058
}

N5. Function call invalidation

A call to a non-pure function inside a narrowed scope drops narrowing for all var bindings whose scope is reachable from the callee. let bindings keep narrowing because the target cannot be reassigned. Pure functions (MEP-15) do not drop narrowing.

This is conservative. A precise analysis would need an escape graph. The workaround is one line (rebind the narrowed value to a fresh let), so the conservative cut is fine.

N6. Closure boundary

Entering a closure (function expression, query subquery) resets narrowing to the declared type. The closure body cannot rely on outer narrowing because the closure may run later, after the outer scope has mutated the binding.

Diagnostics

Two new codes:

  • T058: "dereference of optional %s requires a none guard". Fires on x.f, x[k], x(args) where x : T?. Help text suggests if x != none { ... }, x?.f, or x ?? default.
  • T059: "comparison with none requires an optional operand, got %s". Fires on x == none / x != none where x : T (non-option). Help text suggests removing the comparison or changing the binding's type.

T058 is the main one. T059 catches a common confused-state mistake ("did I forget to type this as T??").

Runtime model

A T? value is either the unwrapped T value or a sentinel nil. There is no Some/None box at the runtime layer; the option-ness lives in the type system. The VM ops needed:

  • OpNoneCoalesce: pop two, push LHS if non-nil else RHS. Already trivially expressible with OpJumpIfNil.
  • OpSafeField / OpSafeIndex: checked variants that produce nil instead of panicking on a nil receiver.
  • OpIsNone: already present (or trivially compiled from OpEqNone).

Join compilation continues to emit nil for unmatched rows, which it already does.

Standard library impact

A few stdlib signatures change:

  • first(xs: list<T>) : T? (was T, panicked on empty).
  • last(xs: list<T>) : T?.
  • find(xs: list<T>, pred: fun(T): bool) : T?.
  • get(m: map<K, V>, k: K) : V?. Explicit form; m[k] does the same thing now.

Callers either narrow or use ??. The migration is mechanical and ships as one PR alongside R8.

Design space, with comparisons

Languages that shipped null safety fall into two families. The algebraic family treats Option as a tagged union, accessed via pattern match or monad combinators (Haskell, OCaml, Rust, Scala, F#). The annotational family puts a nullable annotation on otherwise-non-null reference types and adds safe-call operators with flow narrowing (Kotlin, Swift, Dart, TypeScript strict, C# 8+). Mochi sits closer to the annotational family because the T? syntax and the flat Option shape are already there, but borrows the soundness guarantee of the algebraic family by refusing force unwrap.

Algebraic family

Haskell (Maybe). data Maybe a = Nothing | Just a. Access via pattern matching, fmap, >>=. No null literal in the language at all. Total, composes through monad operations, no runtime null anywhere. The cost is verbosity; chained access without do-notation is heavy, and readers new to >>= find it opaque. Mochi has no do-notation, so this style would feel heavy.

OCaml (option). type 'a option = None | Some of 'a. Same shape as Haskell. The standard library has two parallel APIs in places (List.find_opt vs List.find), which works around the verbosity at the cost of doubling the surface.

Rust (Option<T>). The most successful algebraic option in a mainstream language. Adds ? postfix for early-return propagation: let x = thing()? returns None from the enclosing function if thing() is None. Also unwrap() and expect(msg) panic explicitly; those are the force-unwrap escapes. Pattern matching is the canonical consumer. Mochi takes the Option<T> shape but omits unwrap/expect, because the user's brief is "no panic".

Scala (Option). Algebraic, with getOrElse, map, flatMap. Also .get, which is the force-unwrap and a code smell. The community guidance is "don't use .get"; this MEP makes that a language rule rather than a guideline.

F# (Option). Same shape; Option.defaultValue for coalesce, Option.bind for chained access. No special operators.

Annotational family

Kotlin (T?). Adds ?., ?: (Elvis, same role as ??), !! (force unwrap that throws NPE), and smart casts (Kotlin's name for flow narrowing). !! is the explicit escape hatch; Kotlin needed it for Java interop. Smart casts invalidate on mutation, closure crossing, and impure calls, the same shape as our N4/N5/N6. Mochi adopts the smart-cast story and drops !!.

Swift (Optional<T>, T?). ?., ??, ! force unwrap, and if let x = optional { ... } for narrowing-with-rebinding. Pattern matching via switch. The ! is the usual panic source; Apple's style guides discourage it. Mochi's omission is stricter.

Dart (sound null safety, 2.12+). T?, ?., ??, !, late T for deferred initialization. Shipped with a migration tool that rewrote codebases mechanically. The retrospective from the Dart team ("Dart null safety, six months in", 2021) is the canonical case study for retrofitting null safety to an established codebase.

TypeScript (strict mode). T | undefined and T | null as type-level unions, not a single ? type. ?., ??, narrowing via typeof, in, and != null. ! for non-null assertion. The narrowing engine is mature; it is the model we lean on most.

C# (8+, nullable references). Opt-in feature gate; string? vs string. ?., ??, ! (null-forgiving), flow narrowing. Annotation-only; the runtime still allows null. Mochi cannot use the annotation-only model because Option is real at runtime.

Zig (?T). ?T is sugar for Option(T). orelse for coalesce, if (x) |val| { ... } for narrowing-with-bind, .? for force unwrap (panics in debug, undefined in release, which is the worst of both worlds). Mochi takes the type shape but again no force unwrap.

Why Mochi sits in the annotational family

The choice is pragmatic. T? and none are already on the surface. Forcing users to write Some(v) and None and match x { Some(v) => ..., None => ... } for every dereference would be a fight against the surface we already shipped. The annotational style (?., ??, narrowing) is what T? was designed for.

The !! escape is what makes Kotlin, Swift, and Dart unsafe in practice. Removing it loses one ergonomic shortcut and gains a hard guarantee: a Mochi program that type-checks never panics at a none dereference. That guarantee is worth more than the shortcut. When the programmer knows a value is non-none, narrowing usually proves it. When it cannot, the program is better off being made explicit (??, match, or restructuring) than carrying an unmarked panic site.

Why not the algebraic family

A user-defined type Option<T> = Some(T) | None is more uniform and lets pattern matching cover everything. We do not pick it because:

  1. It would re-order the migration. Every existing T? would need rewriting, and the join-binding work needs the option discipline to be ergonomic now.
  2. Pattern matching on every dereference reads heavier than if x != none.
  3. The performance model is worse. Some(v) is a boxed wrapper; the annotational form runs on a sentinel-or-value layout with no allocation.

We can layer Some(v) / None constructor syntax on top later (it desugars to value-or-none) if pattern matching ergonomics ever demand it. The MEP picks the smaller surface.

A short note on the billion dollar mistake

Hoare's 2009 framing was that giving every reference type a null inhabitant was a default he could not justify. The fix is one of two: separate the optional and non-optional forms at the type level (annotational), or remove null entirely (algebraic). Both work. The expensive part of the mistake is having no fix at all; every language that added one paid migration cost but kept the safety win. Mochi has the rare luxury of choosing before the codebase grows, so the migration here is in fixtures rather than user code.

Rationale

No force unwrap

This is the most-debated point. The arguments for !!:

  • "I know it's not none; the type system doesn't."
  • "Pattern matching is too verbose for the third nested field."
  • "Interop with a less-strict source (file, network, external API) often gives me T? when I know it's T."

The counters:

  • If the type system doesn't know, make it know. Narrowing handles the first case in idiomatic code.
  • Verbosity is a real cost, but ?. plus ?? chains absorb most of it.
  • Interop is the strongest argument. Our answer: parsing functions return Option<T> explicitly, and the user picks the fallback with ??. The escape is a default value, not a panic.

A force-unwrap operator that panics is a feature that can never not be a footgun. Kotlin's experience is the cleanest example: !! is the single biggest source of NPEs in null-safe Kotlin code, because programmers reach for it when they are rushed. The shortcut to "make the type checker happy" routes around the safety property the type checker was supposed to provide.

Mochi takes the strict cut: nothing turns T? into T without either narrowing or a non-none fallback. The ?? operator is the only T-returning escape, and it requires a real fallback value.

Smart-cast narrowing, not separate patterns

We narrow x : T? to x : T inside if x != none { ... }. The alternative is a Swift-style if let y = x { ... } that introduces a new binding. The smart-cast form is shorter and matches Kotlin and TypeScript. The downside is that mutation can invalidate narrowing in subtle ways (N4/N5/N6); we accept that and document the invariants.

Map index returns V?

This is the strongest motivation in the spec. Today m[k] is the single most common source of unintended nones in Mochi programs. Retyping it to V? forces the caller to handle the miss case at every site. Yes, it is verbose for the "I just put it in" case; the verbosity tax is what makes the language safe.

The fallback for the put-then-get pattern is m[k] ?? default_value, or match m[k] { none => ..., _ => /* narrowed */ } for non-trivial cases. Three extra characters for the coalesce form is cheap.

Asymmetric list vs map indexing

xs[i] : T and m[k] : V?. We chose this over xs[i] : T? because:

  • Lists are dense. Index-in-range is the common case and the out-of-range case is a bug where panic is informative.
  • Maps are sparse. Key-absent is normal data shape and the program should handle it.
  • A future bounds-narrowing MEP can prove i < len(xs) and tighten the list case.

Guarded list access uses xs?[i] on a list<T>? value (e.g. the result of a left-joined list field), or if i < len(xs) { xs[i] } with the explicit bound check.

Join retyping

Forced by MEP-14. The MEP-14 doc says the item is deferred and needs option-unwrap discipline. This MEP is that discipline. R10 is the rule MEP-14 cites.

Renaming null to none

The point of the rename is that there is no null reference in the model, only an Option<T> constructor at the empty side. Calling it null invites users to think of it as a pointer, which it is not. The rename is cheap because the language is young and the parser alias keeps the migration linear: stage 1 lets null keep working, stage 5 removes it.

Backwards compatibility

This is a breaking change. The breakage points and the migration paths:

B1. Map index m[k]

  • Before: m[k] : V, nil at runtime on miss.
  • After: m[k] : V?.
  • Migration: add ?? default or match at the use site. The "I know this key is present" case rewrites to m[k] ?? unreachable_default, but unreachable_default is itself the kind of thing this MEP discourages. The real fix is usually match m[k] { none => panic("invariant"), v => v }, and the better fix is restructuring to track the invariant in types.

B2. Stdlib that returned T with hidden nil

first, last, find. Affected callers narrow or coalesce.

B3. Left/right/outer join right-side field access

TPC-DS q40 uses if cr == none { 0.0 } else { cr.refunded }, which is the narrowed form and works after narrowing lands. TPC-DS q93 uses sr != none && sr.sr_reason_sk == ..., the short-circuit form, which also works after narrowing. The cases that break are the ones that dereference without a guard; those are bugs that the type checker now catches.

B4. none flowing into T slots

Already closed (MEP-10 A2). No new breakage.

B5. null keyword rename

Stage 1 introduces none and accepts null as a parser alias with a deprecation warning. Stage 5 removes the alias.

Migration staging

The work lands in five stages, each a separate PR with fixture pinning:

  1. Narrowing core. Implement N1/N2/N3 on T? bindings. Add T058 for unguarded dereference. No surface syntax additions. Stage 1 alone breaks a handful of hand-coded fixtures that dereference T? bindings.
  2. ?. and ?[ ]. Parser plus checker plus VM. Optional but makes dataset fixture migration shorter.
  3. ??. Parser plus checker plus VM.
  4. Map index retyping. Change m[k] : V to V? in checkPostfix. Rewrite stdlib signatures and fixtures.
  5. Join retyping. Implement R10 in checkQueryExpr. Rewrite affected query fixtures. Also remove the null alias and finish the none rename.

The stages can interleave with the compiler-fixture rewrite once we re-enable the slow-tagged compiler tests, but those are out of scope for MEP-16 itself.

Reference implementation

Forward-looking; each pointer becomes a real file:line as the stages ship.

  • types/check_expr.go checkPostfix: ?., ?[ ], m[k] : V?.
  • types/check_expr.go checkBinaryExpr: ?? and the == none / != none typing.
  • types/check_expr.go checkQueryExpr: join binding retyping (R10).
  • types/narrow.go (new): flow narrowing engine. Tracks per-binding option-ness through an if / match / && / || walk. Invalidates on N4/N5/N6.
  • types/errors.go: T058 (unguarded dereference), T059 (none comparison on non-option).
  • parser/parser.go: none keyword, ?., ?[ ], ?? tokens and productions, null-as-alias parser hook (removed in stage 5).
  • runtime/vm/: OpSafeField, OpSafeIndex, OpNoneCoalesce. Join codegen continues to emit nil for unmatched rows.

Open questions

  • ? postfix early-return. Rust-style expr? inside a function returning Option<T> propagates None upward. Useful in chains, adds one more operator. Defer to a follow-up MEP unless it falls naturally out of the stage-3 work.
  • Some(v) / None constructor syntax. Layered sugar over none-or-value. Helps when chained value flows make the option-ness implicit. Defer.
  • Stable-expression narrowing. Narrowing x.y.z rather than just x. Needs a purity proof on the path. Defer.
  • late T style deferred initialization. Allows let x: T to defer assignment without making the type T?. Solves the "I will set this before reading" pattern. Defer; users write T? and narrow.
  • List bounds narrowing. Prove i < len(xs) from surrounding conditions. Big project; defer.

References

This document is placed in the public domain.