Skip to content

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 None
if found is not None:
greet(found)
# 2. Early return
if found is None:
return
greet(found)
# 3. isinstance
if isinstance(found, str):
greet(found)
# 4. guard
guard f = found else: return
greet(f)
# 5. or-fallback
greet(found or "anonymous")
# 6. assert (v0.9.0) — standard Python static-checker idiom
assert found is not None
greet(found)
# 7. Post-while-loop (v0.9.0) — body sets it, loop must have exited
mut cfg: Config? = None
while cfg is None:
cfg = load_next()
greet(cfg) # narrowed to non-None

See 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 narrow xs[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 on u’s fields.
  • Across a call, for module globals — a call can rebind a module global through global NAME in 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