Skip to Content

Nullable

T? means “a value or nothing”. Non-nullable types are guaranteed non-null at compile time; null checks only make sense on nullable types. The basic syntax (??, ?., if val) is covered in Operators and Conditional Branches; this page focuses on the type-level rules.

Four Ways to Unwrap

To get the T back out of a nullable value there are exactly four routes: ?? supplies a default, ! asserts, ?. short-circuits a chain, and null-check flow narrows:

func FindUser(id: int) -> string? { if id == 1 { return "a"; } return null; } val name = FindUser(1); print(name ?? "unknown"); print(FindUser(2) ?? "unknown");
a unknown

The ! assertion declares “there must be a value here”; at runtime a null raises MS6045. ?? chains for multi-level fallbacks:

val merged = name ?? FindUser(2) ?? "nobody"; print(merged); var nick: string? = "Jo"; print(nick!.Length); print(nick == null);
a 2 False

Null-Check Flow Narrowing

An == null check does more than produce a bool: it produces a “null-state fact” that propagates along the control flow. After a guard-style check (the null branch ends in return / fail / crash), the compiler treats the value as non-null:

func GuardStyle(id: int) -> string { val name = FindUser(id); if name == null { return "anonymous"; } return $"found {name.Length} char"; } print(GuardStyle(1), GuardStyle(2));
found 1 char anonymous

After the guard, name.Length is legal without ?. or !. The block of a positive check if name != null { } narrows the same way. Narrowing established on the left of && is visible on the right (name != null && name.Length > 0).

Arithmetic Propagation

When a nullable value takes part in arithmetic, the result is nullable too: if either side is null, the whole expression evaluates to null:

val a: int? = 2; val b: int? = null; print(a + 1, b ?? -1); print(a + b);
3 -1 null

Comparisons always produce a bool (null may take part in == / != checks), but ordering comparisons (< <= > >=) reject nullable operands outright: null has no order, so apply ?? for a default first, then compare.

Narrowing Propagation and Invalidation

Narrowing propagates along the control flow and also expires after certain operations. The complete list of common forms:

  • && chains: narrowing established on the left is visible on the right (the whole name != null && name.Length > 0 is legal).
  • Ternary true arm: narrowing holds inside the true arm of name != null ? name.Length : 0.
  • Field paths: after user.nick != null, user.nick.Length works directly.
  • Reassignment invalidates: after o = expr, the state is recomputed from the type of expr.
  • Call poisoning: if any function call happens between the null check and the read, narrowing on field paths is fully invalidated; bare-name locals are unaffected.
var s: string? = "a"; if s != null && s.Length > 0 { print("long", s.Length); } val t = s != null ? s.Length : 0; print(t);
long 1 1

Null-Coalescing Assignment and Null-Conditional Indexing

??= assigns only when the left side is null; ?[i] extends the null-conditional to indexing:

var who: string? = null; who ??= "default"; print(who); var arr: int[]? = [1, 2, 3]; print(arr?[1] ?? -1); arr = null; print(arr?[1] ?? -1);
default 2 -1

The Conservative Side of Boxed Scalars

Narrowing for boxed scalars is currently conservative: boxed scalars (int? / float? / bool? / decimal?) do not narrow after a null check. Inside a while n != null body, n is still int?, ordinal comparisons are still rejected; land the value with ?? first, then compute:

var n: int? = 3; while n != null { val cur = n ?? 0; print("round", cur); if cur <= 1 { n = null; } else { n = cur - 1; } }
round 3 round 2 round 1
  • Variables captured by a lambda or spawn, generic parameters, and vector or tuple leaf slots do not narrow either.
  • Nullable references to strings and custom classes are not subject to this; null-check narrowing applies in full.
  • Nullable tuples themselves narrow as usual: inside a null-check branch you can destructure directly; see Tuple.

Notes

  • Assigning null to any position of a non-nullable type is an error.
  • Comparing a non-nullable value against null is also an error outright; null checks only make sense on nullable forms.
  • Only three things produce narrowing facts: == null / != null checks and is T tests; reassignment recomputes from the new value’s type.
  • A redundant ?? after null-check narrowing raises warning MS3240 (the right branch never runs).
  • Ordering comparisons reject nullable operands (the null side has no order); apply ?? first, then compare.

Nullable Function Types

On a function type the ? attaches to the return type: func(int) -> int? is “a function returning int?”.

For “a function type that may itself be null”, wrap the whole function type in parentheses: (func(int) -> int)?. Call a nullable function only after the usual null-check narrowing:

func Maybe(n: int) -> int? { if n > 0 { return n; } return null; } func On(n: int) -> int { return n; } func main() -> int { val f1: func(int) -> int? = Maybe; print(f1(5) == 5); val f2: (func(int) -> int)? = null; print(f2 == null); val f3: (func(int) -> int)? = On; if (f3 != null) { print(f3(7)); } return 0; }
True True 7
Last updated on October 11, 2026