MEP 16. Null Safety
| Field | Value |
|---|---|
| MEP | 16 |
| Title | Null Safety |
| Author | Mochi core |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-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
| Item | Status |
|---|---|
T? syntax parses (MEP-10 C1) | closed |
none : Option<any> (MEP-10 A2) | closed |
Option<S> <: Option<T> when S <: T | closed |
Assignment widening T -> T? (auto-wrap) | open |
T? rejected in T slot without narrowing | open |
Unguarded field access on T? raises T058 | open |
Unguarded index access on T? raises T058 | open |
== / != 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 */ } narrowing | open |
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?.freads as "ifaholds a value, take fieldf, otherwise none".a?[k]reads as "ifaholds a value, look upk, otherwise none". Works onlist?andmap?.a ?? breads as "ifaholds a value, take it, otherwise evaluateb".
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 seesx : T). - Safe operator:
x?.f,x?[k], or for callsx?.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: insideon,r : T. After the join,r : T?.right join l in xs on cond: insideon,l : T. After the join, the left side becomesT?andl : T.outer join r in xs on cond: insideon,r : T. After the join, both sides becomeT?.
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: insidee,x : T.x == none || e: insidee,x : T.x == none && e: insidee,x : T?(unchanged;xis knownnonebut we keep the option type).x != none || e: insidee,x : T?(unchanged).
N3. match narrowing
For match x { none => arm1, _ => arm2 } with x : T?:
- In
arm1,x : Option<any>(the matched value is thenoneliteral). - 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
%srequires a none guard". Fires onx.f,x[k],x(args)wherex : T?. Help text suggestsif x != none { ... },x?.f, orx ?? default. - T059: "comparison with
nonerequires an optional operand, got%s". Fires onx == none/x != nonewherex : 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 withOpJumpIfNil.OpSafeField/OpSafeIndex: checked variants that produce nil instead of panicking on a nil receiver.OpIsNone: already present (or trivially compiled fromOpEqNone).
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?(wasT, 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:
- 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. - Pattern matching on every dereference reads heavier than
if x != none. - 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'sT."
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
?? defaultormatchat the use site. The "I know this key is present" case rewrites tom[k] ?? unreachable_default, butunreachable_defaultis itself the kind of thing this MEP discourages. The real fix is usuallymatch 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:
- 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 dereferenceT?bindings. ?.and?[ ]. Parser plus checker plus VM. Optional but makes dataset fixture migration shorter.??. Parser plus checker plus VM.- Map index retyping. Change
m[k] : VtoV?incheckPostfix. Rewrite stdlib signatures and fixtures. - Join retyping. Implement R10 in
checkQueryExpr. Rewrite affected query fixtures. Also remove thenullalias and finish thenonerename.
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.gocheckPostfix:?.,?[ ],m[k] : V?.types/check_expr.gocheckBinaryExpr:??and the== none/!= nonetyping.types/check_expr.gocheckQueryExpr: join binding retyping (R10).types/narrow.go(new): flow narrowing engine. Tracks per-binding option-ness through anif/match/&&/||walk. Invalidates on N4/N5/N6.types/errors.go: T058 (unguarded dereference), T059 (none comparison on non-option).parser/parser.go:nonekeyword,?.,?[ ],??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-styleexpr?inside a function returningOption<T>propagatesNoneupward. 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)/Noneconstructor syntax. Layered sugar overnone-or-value. Helps when chained value flows make the option-ness implicit. Defer.- Stable-expression narrowing. Narrowing
x.y.zrather than justx. Needs a purity proof on the path. Defer. late Tstyle deferred initialization. Allowslet x: Tto defer assignment without making the typeT?. Solves the "I will set this before reading" pattern. Defer; users writeT?and narrow.- List bounds narrowing. Prove
i < len(xs)from surrounding conditions. Big project; defer.
References
- Tony Hoare. Null References: The Billion Dollar Mistake. QCon London, 2009. https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/
- Rust. Option in std::option. https://doc.rust-lang.org/std/option/
- Kotlin. Null safety. https://kotlinlang.org/docs/null-safety.html
- Swift. Optional Chaining. https://docs.swift.org/swift-book/documentation/the-swift-programming-language/optionalchaining/
- Dart team. Sound null safety. https://dart.dev/null-safety
- Bracha and Griesemer. Dart null safety: A retrospective. Talk transcripts circa 2021.
- TypeScript. Strict null checks. https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#strictnullchecks
- C#. Nullable reference types. https://learn.microsoft.com/dotnet/csharp/nullable-references
- Zig. Optionals. https://ziglang.org/documentation/master/#Optionals
- Haskell. Data.Maybe. https://hackage.haskell.org/package/base/docs/Data-Maybe.html
- Scala. scala.Option. https://www.scala-lang.org/api/current/scala/Option.html
Copyright
This document is placed in the public domain.