Unions, Optional and narrowing

typing · Essentials · 10 min

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: return then user is User afterwards.
  • isinstance(x, str) narrows to str in the if branch and the other members in else.
  • Truthiness (if user:) narrows away None, but also excludes falsy values like 0 and "", so prefer is not None for numbers and strings.
  • assert x is not None narrows too, but it is removed under python -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.

Check yourself

  1. Inside if x is not None: where x: int | None, what does the checker consider x?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.Optional