None-related crashes are the most common Python bug, and unions make them visible.
A union int | str means "either type". Since Python 3.10 the | syntax works everywhere; older code uses Union[int, str]. The most common union is X | None, which is what Optional[X] means.
def find(user_id: int) -> User | None: ...Given a union, the checker only allows operations valid for every member. You can't call .name on User | None until you prove it isn't None. That proof is narrowing: the checker follows your control flow.
if user is None: returnthenuserisUserafterwards.isinstance(x, str)narrows tostrin theifbranch and the other members inelse.- Truthiness (
if user:) narrows awayNone, but also excludes falsy values like0and"", so preferis not Nonefor numbers and strings. assert x is not Nonenarrows too, but it is removed underpython -O, so use it for invariants, not input validation.
A parameter defaulting to None should be annotated X | None = None; checkers no longer treat = None as implicitly optional.
Example
def parse_port(raw: str | None) -> int:
if raw is None: # narrowed: raw is str below
return 8080
return int(raw)
def describe(value: int | str | list[int]) -> str:
if isinstance(value, int):
return f"int {value + 1}" # value: int
elif isinstance(value, str):
return value.upper() # value: str
return f"{len(value)} items" # value: list[int]
print(parse_port(None), parse_port("9000")) # 8080 9000
print(describe(41), describe("hi"), describe([1, 2]))
# int 42 HI 2 items
def bad(name: str | None) -> int:
return len(name) # checker error: "str | None" passed to len()Remember
- A | B means either; X | None is Optional[X]
- Narrow with is None, isinstance or early returns
- Annotate None defaults as X | None = None
Watch out for
- Narrowing with if value: when 0 or '' are valid values.