Nullable Types (T?)
T? is sugar for T | None. Plain T cannot hold None — that is the core of Typhon’s non-nullable-by-default rule. The checker tracks nullability flow-sensitively and narrows T? to T after the usual checks.
Spelling
| Want | Typhon | Emitted |
|---|---|---|
Non-nullable int | int | int |
Nullable int | int? | int | None |
| Nullable list of nullable strings | list[str?]? | list[str | None] | None |
| Nullable dict value | dict[str, int?] | dict[str, int | None] |
Nullable param with default None | name: str? = None | name: str | None = None |
T? is the preferred spelling. T | None is also accepted silently. Optional[T] from typing is accepted with one advisory warning (tyc::typing_alias_in_annotation, “prefer T? here”); the migrator rewrites it. Internally Typhon represents nullability as Nullable[T], but the emitter always lowers to T | None.
Default values
T? does not auto-default to None. If you want both nullable and defaulted, spell it:
def find(name: str?) -> int?: ... # caller must pass an arg (even None)def find(name: str? = None) -> int?: ... # caller may omitNarrowing forms
The checker recognises five narrowing forms:
1. is None / is not None
def f(x: int?) -> int: if x is None: return 0 return x # narrowed to int2. Early-return from is None
def f(x: int?) -> int: if x is None: return 0 return x + 1 # narrowed to int by reachability3. isinstance(x, T)
def f(x: int | str | None) -> str: if isinstance(x, str): return x # narrowed to str if isinstance(x, int): return str(x) # narrowed to int return ""4. guard x = expr else: ...
def f(maybe: int?) -> int: guard x = maybe else: return 0 return x # narrowed to int5. Truthiness (limited)
Truthy / falsy narrowing works on nullable types specifically:
def f(x: str?) -> int: if x: # narrows x to str (non-empty) return len(x) return 0Width-preserving operations
Some operations preserve nullability through their type:
let xs: list[int]? = get_list()let ys: list[int]? = xs # still nullablelet s: list[int] = xs or [] # `or` collapses None → []or with a fallback is a common idiom for “narrow to T”:
let name: str = maybe_name or "anonymous"How T? emits
def greet(name: str?) -> str: ...def greet(name: str | None) -> str: ...Existing Python type checkers (mypy, pyright, IDEs) handle T | None natively. There is no Typhon-specific type to learn at the interop boundary.
Common cases
dict.get(k) returns V?
let counts: dict[str, int] = {"a": 1}let n: int? = counts.get("missing") # nullablere.match returns Match?
import relet m: re.Match? = re.match(r"\d+", "123")if m is not None: print(m.group())Optional-chaining is not supported
Typhon does not ship the ?. operator. Use guard / if x is not None:
guard u = find_user(id) else: return Noneprint(u.name)Common mistakes
Calling a method on a T? without narrowing
let s: str? = maybe_str()print(s.upper()) # ❌ tyc::nullable_useFix: narrow first (if s is not None:) or use guard.
Returning None from a T function
def find(id: int) -> str: return None # ❌ None not assignable to strFix: change the signature to -> str? or return a real str.
Optional[T] from typing
from typing import Optionaldef f(x: Optional[int]) -> None: ... # accepted with a warning — prefer `x: int?`tyc migrate rewrites this automatically.
Where next
- Flow Narrowing — every form the checker recognises.
guard— the dedicated early-return-with-narrowing form.- Result reference — when “missing” is really a typed error.