Nullability Errors
tyc::nullable_use
Passing or using a T? value where a T is required, without narrowing first:
def greet(name: str) -> None: ...def find_user(id: int) -> str?: ...
let found: str? = find_user(1)greet(found) # ❌Fix: narrow before use. Seven forms (the last two added in v0.9.0):
# 1. is None / is not Noneif found is not None: greet(found)
# 2. Early returnif found is None: returngreet(found)
# 3. isinstanceif isinstance(found, str): greet(found)
# 4. guardguard f = found else: returngreet(f)
# 5. or-fallbackgreet(found or "anonymous")
# 6. assert (v0.9.0) — standard Python static-checker idiomassert found is not Nonegreet(found)
# 7. Post-while-loop (v0.9.0) — body sets it, loop must have exitedmut cfg: Config? = Nonewhile cfg is None: cfg = load_next()greet(cfg) # narrowed to non-NoneSee Flow Narrowing for every form the checker recognises.
Note on assert: disabling assertions with python -O removes the runtime guard, so safety-critical narrowing should still go through guard or if x is None: return. assert is best as a “this can’t happen” checkpoint inside functions that already validated their inputs.
Nullable receivers on a dotted path
Dereferencing a nullable field — self.conn.execute(), cfg.db.host, resp.body.decode() — is reported too. Before v1.0.0-alpha.7 it was not checked at all: the diagnostic was gated on the receiver being a bare name, so the single most common non-nullability bug in real code was invisible.
It landed at warn level in v1.0.0-alpha.7, because a check that had never run could flag a field that happens always to be populated at the dereference. Since the 2026-09-30 review it is an error by default — Rule 3 is the language’s headline guarantee. Relax it during a migration:
[strictness]nullable-use = "warn"The bare-name form has always been, and remains, an error. Narrowing follows and chains through attribute paths (if b is not None and b.val is not None: narrows both b and b.val in the body), and a receiver that is always None (None.attr, a -> None call’s result) reports under the same code with wording that does not suggest a guard.
What does not narrow
- Indexed access —
if xs[0] is not None:does not narrowxs[0]. Copy to a local first. - Cross-mutation — an assignment to the path or any prefix of it, or an explicit
u.refresh()in between, invalidates the narrowing onu’s fields. - Across a call, for module globals — a call can rebind a module global through
global NAMEin the callee, so any narrowing on such a global is dropped at the call. Locals are unaffected: nothing can rebind a caller’s local. - Around a loop back-edge — a narrowing established before the loop does not survive into iteration 2 if the body reassigns the name, the attribute path, or (through a call) the global.
Attribute paths are narrowed — see Flow Narrowing.
See Flow Narrowing for the rules and limits.
Where next
- Nullable Types —
T?reference. - Flow Narrowing — narrowing forms.
guard— the sugar form.