typing: Optional, Union, generics & Protocols
Type a small API with Optional/Union, a TypeVar generic, and a structural Protocol.
Precise types for real APIs
In a shared codebase, a function signature is the contract your teammates read before they read your code. def find_user(user_id): ... tells a caller nothing about what comes back. A precise return type like dict | None says "you might get nothing, handle it" before anyone runs the code. On a data team that is the difference between a null check you wrote on purpose and a NoneType crash in a nightly pipeline at 3am.
| Annotation | Reads as | Modern equivalent |
|---|---|---|
| list[int] | a list whose items are ints | same; built-in generics since 3.9 |
| dict[str, int] | a dict from str keys to int values | same |
| tuple[int, str] | exactly two items, an int then a str | tuple[int, ...] for any number of ints |
| int | None | an int, or nothing | replaces Optional[int] |
| int | str | either an int or a str | replaces Union[int, str] |
| Callable[[int], str] | a function taking one int, returning a str | same |
Optional and Union
A value that might be missing is Optional, written X | None (older code writes Optional[X]; they mean the same type). A value that can be one of several types is a Union: int | str.
Returning None from a function you annotated -> dict is a lie a checker will flag. Annotate the honest contract -> dict | None, and every caller is told to handle the missing case. Once a value is dict | None, a checker will not let you subscript it until you narrow it:
u = find_user(7) # u: dict | None
if u is not None:
print(u["name"]) # here u is dict, so u["name"] is allowed
Generics with TypeVar
The demo below returns object | None, so the caller loses the element type. A generic keeps the link between the input type and the output type. TypeVar is the placeholder that stands in for "whatever type came in":
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
return items[0] if items else None
first([10, 20]) # a checker infers int | None, not object | None
Python 3.12 and later write the same thing as def first[T](items: list[T]) -> T | None: with no import.
Protocols (structural typing)
A Protocol describes the shape an object must have. Any object with the right attributes or methods satisfies it, with no base class and no inheritance:
from typing import Protocol
class Named(Protocol):
name: str
def greet(who: Named) -> str:
return "Hi, " + who.name # any object with a .name: str fits
Pitfall: Optional is not an optional argument
A common intern misread: X | None describes the allowed values, not whether the argument can be omitted. def find_user(user_id: int | None) still requires user_id; calling find_user() raises TypeError: find_user() missing 1 required positional argument: 'user_id'. Adding None to the type does not add a default. To make a parameter skippable you give it one: user_id: int | None = None. Keep the two ideas separate: the type says what values are legal, the default says whether the caller can leave it out.
Interview nuance: Python type hints are not enforced at runtime. The interpreter records them in __annotations__ but never checks them, so list[T] is no runtime guarantee that every element is a T, and a wrong annotation never raises on its own. A function annotated -> dict will happily return None; the crash only shows up later in the caller as TypeError: 'NoneType' object is not subscriptable. That is why teams gate merges on mypy or ty: the type check is a proof a static tool runs before the code ships, not a guard the interpreter performs. Contrast pydantic, which does validate values at runtime. So the payoff of -> dict | None is real only if you both annotate honestly and actually run the checker in CI.
def first(items: list) -> object | None:
return items[0] if items else None
print(first([10, 20])) # 10
print(first([])) # NoneApply
Your turn
The task this lesson builds to.
Warm-up (one file): implement find_by_id(rows, target). Return the first dict in rows whose
"id" equals target, or None if none match.
Annotate the return as -> dict | None.
3 hints and 3 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement find_user(user_id) in directory/lookup.py: return the matching user dict from the
read-only USERS list, or None when no user has that id. Annotate the return as -> dict | None.
Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.