Optional, Union, Literal Type Hints
Modern Python type hints (3.10+) replaced most of `typing.Optional` and `typing.Union` with the `X | Y` operator and made `Literal` the right tool for closed string sets. This entry covers `T | None` for nullable values, `X | Y` for unions, and `Literal['a', 'b']` for enum-like APIs, with runtime examples that show why each shape matters.
961 views
11
def find_user(user_id: int) -> dict | None:
"""Return the user dict, or None if no user has that id."""
db = {1: {'name': 'ana'}, 2: {'name': 'ben'}}
return db.get(user_id)
user = find_user(1)
if user is not None:
print(user['name'])
missing = find_user(999)
print(missing) # None
# Pre-3.10 you would write `Optional[dict]` from typing.
from typing import Optional
def legacy(user_id: int) -> Optional[dict]:
return None # exact same meaning as `dict | None`
print(legacy.__annotations__) # {'user_id': <class 'int'>, 'return': typing.Optional[dict]}T | None is the modern way to say 'either a T or None'. It is exactly equivalent to Optional[T] from the typing module, but reads more like English and avoids the import. The pattern is the right shape for 'lookup that might miss', 'value that has not been computed yet', and any boundary where None is a real, valid case. Always pair the return type with an is not None check at the call site so the type checker can narrow the value to T inside the branch.
def parse_id(raw: str | int) -> int:
"""Accept either a string of digits or an int, return an int."""
if isinstance(raw, int):
return raw
return int(raw)
print(parse_id(42)) # 42
print(parse_id('100')) # 100
# Pre-3.10: typing.Union[str, int]
from typing import Union
def parse_id_legacy(raw: Union[str, int]) -> int:
return parse_id(raw)
print(parse_id_legacy('7')) # 7
# Three-way union for fields that can carry several payload shapes:
def summarize(value: int | float | str) -> str:
if isinstance(value, (int, float)):
return f'number: {value:.2f}'
return f'text: {value!r}'
print(summarize(3.14)) # number: 3.14
print(summarize('hello')) # text: 'hello'X | Y (or X | Y | Z) tells the type checker the value is one of those types, not all of them at once. Inside the function, isinstance(value, X) narrows the type so the type checker knows what attributes are available. Use unions sparingly: a function that takes 'either a str or an int' is often two functions trying to share a body. Reach for them most often at API boundaries where the input format is genuinely flexible (e.g., 'id can be the int primary key or the human-readable slug').
from typing import Literal
LogLevel = Literal['debug', 'info', 'warn', 'error']
def log(level: LogLevel, message: str) -> None:
print(f'[{level.upper()}] {message}')
log('info', 'app started')
log('error', 'database down')
# log('verbose', 'oops') # type checker error: 'verbose' is not in Literal[...]
# Mixed-type literal: any of these exact values, not just any int / str.
Mode = Literal['auto', 0, 1, 2]
def set_mode(mode: Mode) -> None:
print(f'mode set to {mode!r}')
set_mode('auto')
set_mode(2)
# Combine with isinstance for runtime narrowing.
def describe(level: LogLevel) -> str:
if level in ('warn', 'error'):
return 'noisy'
return 'quiet'
print(describe('debug')) # quiet
print(describe('error')) # noisyLiteral['a', 'b'] says 'this value must be exactly one of these literals', not just any string. Type checkers reject typos at the call site, which is the whole point: typos in 'verbose' vs 'verbos' for log levels are exactly the kind of bug that survives unit tests. Literal accepts strings, ints, bytes, and True / False / None; it is the lightweight alternative to a full Enum class when you just want a closed set of constant values. Combine it with Final or constants for a clean public API.
