Flow-sensitive typing
Meta: short page, but pays for itself — the alternative is writing ! casts everywhere. Make sure the examples include both narrowing inside a branch and narrowing after a guard clause.
Null narrowing
After a guard
if (x == null) { return "no value" }
# x is now T! here
print(x.length)
Inside a branch
if (x != null) {
# x is T! here
print("got " + x)
}
Else branch
if (x == null) { ... } else {
# x is T! here
}
Loop conditions
for (x != null) {
# x is T! in the body
x = x.next
}
Diverging constructs are narrowing-aware
return,raise,break,continueall count as diverging- code after them in the same scope sees the narrowed type
- a guard whose then-branch diverges narrows the rest of the enclosing scope
else ifchains: the parser wrapselse ifin a Block, so the outer guard's falsy facts still apply afterward- sequential guards accumulate: each narrows independently as forms are processed in order
- a loop condition narrows the loop body (
for (x != null) { … }→xisT!inside)
Type narrowing via case
case (animal) {
c: Cat => c.purr # c is Cat!
d: Dog => d.bark # d is Dog!
}
binding: TypeName => …clauses bind the operand narrowed to the pattern typetry/catchclauses reuse the sameCaseClauseform, so typed catch clauses narrow the bound error the same way- this is how you recover a concrete value from a widened conditional: an
if/elseover divergent branches infers as a union, which acasethen narrows
Conditional result inference (related)
- an
if/elsewhere one branch isnullinfers a nullable type, not non-null - divergent concrete branches widen to their common interface/supertype, or to a union when unrelated
- a discarded divergent conditional is fine; only using the result forces the union/narrowing
Compound conditions
if (x == null or y == null) { raise "missing" }
# both x and y are T! after the diverging guard
- guard with
or: entering the diverging branch means both checks failed, so both narrow afterward - compound
andinside a then-branch narrows both operands in that branch:
if (maybe != null and other != null) {
maybe + other # both T! here
}
Limitations
- narrowing is intra-procedural — calling a function doesn't carry narrowed types across
and-guard does NOT narrow:if (x == null and y == null) { raise … }tells us only that at least one is non-null afterward, so neither narrows individually- field accesses don't narrow: a null check on
h.valdoes not narrow laterh.valaccesses, because each.fieldaccess could return a different value. Workaround: bind to a local first —let v = h.val; if (v == null) { … } - in an
elsebranch where the guard checked== null, the variable is known null (not narrowed toT!) — using it as non-null there errors - narrowing applies to bare symbols (locals, and bare
self-field references inside methods, which parse as plainSymbols)
See also Errors: try, catch, raise (raise/try/catch divergence) and Control flow (guards, loops, case).
Meta: field-narrowing and the and-guard non-narrowing are the two most surprising gaps in practice. Both are now documented with the re-bind-to-a-local workaround.