Flow Narrowing
Flow narrowing is what makes non-nullable-by-default usable. The checker tracks the type of every binding through branches, guards, and early returns — once you’ve eliminated a case, the narrowed type sticks.
What gets narrowed
Narrowing applies to:
- Nullable types —
T?narrows toTafter a check. - Union types —
A | Bnarrows to one side after anisinstancetest. - Sealed unions —
type Shape = Circle | Rectanglenarrows inside amatcharm.
…and to attribute paths, which are tracked by path:
def f(u: User?) -> str: if u is None: return "" return u.name # ✅ u narrowed to User
def g(u: User) -> str: if u.email is None: return "" return u.email.upper() # ✅ u.email narrowed to strAn attribute narrowing is dropped as soon as the base object could have changed — an assignment to the path or to any prefix of it (u.email = …, u = …), a method call on the receiver in statement position (u.refresh()), or a loop body that reassigns the path. Anything the checker cannot prove stable is widened back rather than trusted.
Indexed access is not narrowed — if xs[0] is not None: says nothing about the next read of xs[0], because the list can be mutated in between and the index expression can itself change. Copy into a local first:
def h(xs: list[str?]) -> str: let first: str? = xs[0] if first is None: return "" return first.upper() # ✅ first narrowedNarrowing forms
is None / is not None
def f(x: int?) -> int: if x is None: return 0 return x # narrowed to intEarly-return
If the failure case exits the scope (return, raise, continue, break), the success case is narrowed for the rest of the function:
def f(x: int?) -> int: if x is None: return 0 # `x` is `int` here onwards return x + 1isinstance(x, T)
Single-type and tuple-of-types forms both work:
def f(x: int | str | None) -> str: if isinstance(x, str): return x # narrowed to str if isinstance(x, (int, type(None))): return str(x) # narrowed to int | None return ""guard early-return
def f(maybe: int?) -> int: guard x = maybe else: return 0 return x # narrowed to intTruthiness
def f(x: str?) -> int: if x: # narrows to non-empty str (so non-None) return len(x) return 0Equality with literals
def f(t: Literal["a", "b", "c"]) -> int: if t == "a": return 1 # narrowed to Literal["a"] if t == "b": return 2 return 3match patterns
Each case narrows the scrutinee to the matched variant:
def area(s: Shape) -> float: match s: case Circle(r): return 3.14 * r * r # s narrowed to Circle; r: float case Rectangle(w, h): return w * h # s narrowed to Rectangle case Triangle(b, h): return 0.5 * b * hDestructured names (r, w, h) take the field types.
Class patterns on built-in types (v0.9.0)
case TypeName() as binding: narrows the scrutinee to the named built-in:
def label(x: object) -> str: match x: case str() as s: return f"str:{s}" case int() as n: return f"int:{n}" case _: return "other"Exhaustiveness also recognises case None: + case str() as s: as covering a str? subject — no missing_return even without a wildcard.
assert x is not None (v0.9.0)
The standard Python static-checker idiom narrows the binding under assert:
def f(x: int?) -> int: assert x is not None return x + 1 # narrowed to int since v0.9.0assert is treated as a control-flow gate that raises on the false branch. The narrowing covers the binding for the rest of the scope, exactly like an early-return guard would. Use it sparingly — assert is a runtime check, and disabling it (python -O) removes the guarantee. Prefer guard or if x is None: return for shape that should survive -O.
Post-while-loop narrowing (v0.9.0)
After a while loop whose condition is “this value is None” and whose body reassigns the binding (and no break is present), the post-loop binding is narrowed to non-None:
def load_first(sources: list[Loader]) -> Config: mut y: Config? = None mut i: int = 0 while y is None: y = sources[i].load() i = i + 1 return y # ✅ narrowed to Config since v0.9.0 — was nullable_use beforeThe same pattern works for any narrowing form the while test would have applied to the body — is None, is not None, truthiness — provided no break jumps over the implicit “test must have been false to exit” inference.
Re-widening
A binding’s narrowed type lasts until reassignment. If you reassign with mut, the new type is whatever you put in:
mut x: int? = some_call()if x is not None: x = x + 1 # ✅ still int x = None # re-widens to int? # `x` is `int?` again hereNarrowing inside gather: and with chains
Bindings inside a gather: block or a with-chain are narrowed to their unwrapped types:
gather: user_r = fetch_user(id) posts_r = fetch_posts(id)
let user: User = user_r? # Result[User, E] → User after ?let posts: list[Post] = posts_r?What does not narrow
These cases look like they should narrow but don’t:
Mutated fields
def f(u: User) -> str: if u.email is None: return "" u.refresh() # arbitrary mutation return u.email.upper() # ❌ u.email could be None againFix: copy to a local first, then narrow.
Indexed access
def f(xs: list[int?]) -> int: if xs[0] is None: return 0 return xs[0] + 1 # ❌ xs[0] is a fresh read; not narrowedFix: bind to a local.
Cross-function calls
The checker can’t prove a function preserves a narrowing. If you call a function in between, the narrowing is invalidated:
def f(u: User?) -> str: if u is None: return "" log("got user") # external call return u.name # ✅ this is fine — `u` itself wasn't mutated(Local bindings do persist across calls because the checker treats them as scope-local, not as a fact about the world.)
Implementation note
The narrowing engine lives in tyc-types. Every binding carries a NarrowedType that the checker updates as it walks the AST. The narrowing facts are merged at control-flow joins (if/else end, match end, loop entry).
Narrowing is implemented as a fixed-point over the control-flow graph, with type joins at merge points. The cost is small because Typhon’s narrowing rules are simpler than TypeScript’s (no covariance / contravariance on generic positions inside narrowed types).
Where next
- Nullable Types —
T?reference. - Sealed Unions —
matchexhaustiveness. guard— the dedicated narrowing form.