Optional, Union, and friends¶
This notebook covers the more expressive forms you'll reach for as your typing gets more ambitious: X | None for optionality, Literal for specific-value constraints, Callable for function arguments, TypedDict for structured dicts, and a few others.
Each of these unlocks a whole category of otherwise untypeable code. Most projects use a handful of them routinely.
X | None — the optional pattern¶
Already seen this briefly. It's worth spending a moment on because it comes up constantly — None is Python's standard way of saying "no value":
def find_user(user_id: int) -> dict | None:
# Pretend lookup
if user_id == 42:
return {"id": 42, "name": "Alice"}
return None
user = find_user(42)
if user is not None:
print(user["name"])
missing = find_user(99)
print(missing)
The dict | None return type is a promise to callers: "you might get a dict, you might get None — always handle both". A type-checker will flag code that calls user["name"] without first checking for None, catching a whole class of TypeError: 'NoneType' object is not subscriptable bugs.
On older Python: Optional[dict] from typing is the same thing. Optional[X] is literally defined as X | None. Use X | None on 3.10+ for consistency with the rest of the | union syntax.
Narrowing Optional¶
Type-checkers follow your control flow and narrow types as you check them:
def find_user(user_id: int) -> dict | None:
if user_id == 42:
return {"id": 42, "name": "Alice"}
return None
user = find_user(42)
# Here, the type-checker thinks user is `dict | None`
if user is None:
raise ValueError("user not found")
# Past this point, the type-checker has narrowed user to just `dict`
# — we wouldn't reach here if it were None
print(user["name"]) # no type error
The same narrowing happens inside an if user is not None: block, or after an assert user is not None. It's one of the genuinely nice features of gradual typing — the type-checker thinks about your code the way you do.
Literal — specific values¶
Sometimes a parameter doesn't take "any string" — it takes one of a few specific strings. Literal["a", "b", "c"] expresses exactly that:
from typing import Literal
def align(text: str, direction: Literal["left", "right", "center"]) -> str:
if direction == "left":
return text.ljust(20)
elif direction == "right":
return text.rjust(20)
return text.center(20)
print(repr(align("hi", "left")))
print(repr(align("hi", "right")))
print(repr(align("hi", "center")))
# align("hi", "middle") # type error: not one of the literal values
This is far better than str — the type-checker flags typos ("centre" vs "center") and your editor autocompletes the valid values. Works for strings, ints, booleans, and None.
For longer fixed sets of values, an Enum is usually the cleaner choice. Literal is ideal for ad-hoc "one of these three strings" cases.
Callable — typing functions passed as arguments¶
Functions are first-class in Python — you pass them around, store them in dicts, return them from other functions. Callable[[ArgType1, ArgType2], ReturnType] annotates that shape:
from collections.abc import Callable
def apply_twice(fn: Callable[[int], int], x: int) -> int:
return fn(fn(x))
print(apply_twice(lambda n: n + 1, 5)) # 7
print(apply_twice(lambda n: n * n, 3)) # 81
Callable[[int], int] reads as "a callable that takes one int and returns an int". The first list is the argument types; the second entry is the return type.
For "any callable" regardless of signature, use Callable[..., T] — the literal ... (ellipsis) means "any arguments". Less precise but occasionally necessary.
For complex signatures — keyword args, optional args — Callable gets clunky. At that point either define a Protocol or accept that typing is only catching so much.
TypedDict — structured dicts¶
A plain dict[str, int] says "all keys are strings, all values are ints". A dict[str, str | int | list[int]] says "mixed types allowed" — but then you've lost all useful information about which keys have which types.
TypedDict lets you describe a dict where each specific key has a specific type. It's the way to type configuration dicts, JSON payloads, and similar record-shaped data:
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
active: bool
user: UserDict = {"id": 42, "name": "Alice", "active": True}
print(user)
print(user["name"])
# user = {"id": 42, "name": "Alice"} # type error: missing 'active'
# user["id"] = "x" # type error: id should be int
At runtime, a TypedDict is just a dict — no performance penalty, no restriction on what you can put in it if you bypass the type-checker. It's purely a compile-time aid for the type-checker to reason about the shape.
Optional keys go via NotRequired (Python 3.11+) or the total=False class parameter:
from typing import TypedDict
try:
from typing import NotRequired # Python 3.11+
except ImportError:
from typing_extensions import NotRequired # backport for older
class UserWithOptionalEmail(TypedDict):
id: int
name: str
email: NotRequired[str] # this key may or may not be present
alice: UserWithOptionalEmail = {"id": 42, "name": "Alice"} # ok
bob: UserWithOptionalEmail = {"id": 43, "name": "Bob", "email": "b@x"} # ok
print(alice)
print(bob)
When to reach for a TypedDict vs a dataclass: if the data is already a dict (JSON from an API, a parsed config, a pandas record), TypedDict lets you type it without converting to a class. If you're building the data in Python, a dataclass gives you better autocomplete and attribute access. See the data structure recipe for the full decision tree.
Any vs object¶
Both mean "any type", but they behave very differently for the type-checker:
Anysays "anything goes — don't check, don't narrow, don't flag". It's the escape hatch from type-checking. Operations on anAnyare all valid by definition.objectsays "any Python object, but I only know about the base Object API". Operations beyond whatobjectsupports are flagged as errors. Narrowing viaisinstanceworks.
from typing import Any
def with_any(x: Any) -> None:
x.foo() # type-checker: fine, Any allows anything
x + 1
x["key"]
def with_object(x: object) -> None:
pass
# x.foo() # type error: object has no .foo()
# x + 1 # type error: unsupported operand types
# But this works:
if isinstance(x, str):
x.upper() # narrowed to str, all str methods available
print("Both compile — the difference is what the type-checker would flag.")
Rule of thumb: prefer object over Any whenever possible. object says "I don't care about the type" but still lets the checker help you; Any is the off-switch.
type[X] — the class itself, not an instance¶
Occasionally you want to annotate "the class X, not an instance of X". type[X] is how:
def make_instance(cls: type[int], value: str) -> int:
return cls(value) # calling the class to construct an instance
x = make_instance(int, "42")
print(x, type(x))
Common in factory patterns, dependency injection, and whenever you pass a class as an argument rather than an instance. type[Exception] is "any exception class".
Final, ClassVar, and a few more¶
Worth knowing but you won't reach for them often:
Final[T]— declares a variable that shouldn't be reassigned. The type-checker flags reassignment. Useful for constants.ClassVar[T]— inside a class, distinguishes a class-level attribute (shared by all instances) from an instance attribute. Matters for dataclasses, whereClassVarfields are excluded from__init__.NewType("UserId", int)— creates a distinct type from an existing one. AUserIdis anintat runtime but the type-checker treats them as different, preventing you from passing a random int where a user ID is expected.Protocol— structural typing (duck typing with type-checker support). "Anything with a.read()method that returns bytes." Covered in thetypingreference.
Exercise¶
Annotate the following. Use the most precise form available.
# 1. A function that takes 'GET', 'POST', or 'DELETE' and returns a request string
def build_request(method, path):
return f"{method} {path}"
# 2. A function that looks up a user by id and returns the user dict or None
def find_user(user_id, db):
return db.get(user_id)
# 3. A retry helper that takes a function with no arguments, a number of attempts,
# and returns whatever the function returns
def retry(fn, attempts=3):
last_error = None
for _ in range(attempts):
try:
return fn()
except Exception as e:
last_error = e
raise last_error
# 4. A config dict with 'host' (str), 'port' (int), and optional 'debug' (bool)
config = {"host": "localhost", "port": 8080}
print(build_request("GET", "/users"))
print(find_user(1, {1: {"name": "Alice"}}))
print(retry(lambda: 42))
Solution
from typing import Literal, TypedDict, NotRequired, TypeVar
from collections.abc import Callable, Mapping
# 1. Literal for the method; str for the path
Method = Literal["GET", "POST", "DELETE"]
def build_request(method: Method, path: str) -> str:
return f"{method} {path}"
# 2. Optional return; Mapping for the db parameter
def find_user(user_id: int, db: Mapping[int, dict]) -> dict | None:
return db.get(user_id)
# 3. TypeVar so the return type matches what fn returns
T = TypeVar("T")
def retry(fn: Callable[[], T], attempts: int = 3) -> T:
last_error: Exception | None = None
for _ in range(attempts):
try:
return fn()
except Exception as e:
last_error = e
assert last_error is not None
raise last_error
# 4. TypedDict with a NotRequired key
class Config(TypedDict):
host: str
port: int
debug: NotRequired[bool]
config: Config = {"host": "localhost", "port": 8080}
Notes:
Method = Literal[...]extracts the type alias so it's reusable and self-documenting.retry'sTties the return type to whateverfnproduces — pass aCallable[[], int], get anint.- The
assert last_error is not Noneinsideretrynarrows the type for the followingraisestatement.
Recap¶
X | None(orOptional[X]) for parameters or returns that might be missing.Literal["a", "b"]for specific-value constraints.Callable[[Arg1, Arg2], Return]for functions passed as arguments.TypedDictfor dicts where each key has a known type.Anydisables checking;objectis "any Python object" with checking still active.type[X]for the class itself rather than an instance.
That's the shape of the language. The Recipes and Reference cover specific tasks and lookup tables.