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
unknownThe ! 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
FalseNull-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 anonymousAfter 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
nullComparisons 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 wholename != null && name.Length > 0is legal).- Ternary true arm: narrowing holds inside the true arm of
name != null ? name.Length : 0. - Field paths: after
user.nick != null,user.nick.Lengthworks 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
1Null-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
-1The 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/!= nullchecks andis Ttests; 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