Skip to main content

typing: Optional, Union, generics & Protocols

Level 3: Level 3: Patternsmedium18 mintypingoptionalgenericsprotocols

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.

Table
The right column is why two spellings appear in real codebases. Optional[int] and Union[int, str] came from the typing module and still work, but the | forms read closer to how you would say them aloud and need no import.
AnnotationReads asModern equivalent
list[int]a list whose items are intssame; built-in generics since 3.9
dict[str, int]a dict from str keys to int valuessame
tuple[int, str]exactly two items, an int then a strtuple[int, ...] for any number of ints
int | Nonean int, or nothingreplaces Optional[int]
int | streither an int or a strreplaces Union[int, str]
Callable[[int], str]a function taking one int, returning a strsame
The right column is why two spellings appear in real codebases. Optional[int] and Union[int, str] came from the typing module and still work, but the | forms read closer to how you would say them aloud and need no import.

Optional and Union

Check yourself
You are reading a codebase where one module writes Optional[int] and another writes int | None. What is the difference between them?

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

Check yourself
A helper is declared: def first(items: list) -> object | None. You write n = first([10, 20]) and then n + 1, and run mypy. What does mypy report?

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)

Check yourself
greet is typed to take a Named protocol, which requires a name: str attribute. You pass it a User object from a third-party library. User has a name attribute but knows nothing about your Named class. Does the checker accept the call?

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

Check yourself
A function is declared: def find_user(user_id: int | None) -> dict | None. A caller writes find_user() with no arguments at all. What happens?

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.

Worked example (Python)
def first(items: list) -> object | None:
    return items[0] if items else None


print(first([10, 20]))   # 10
print(first([]))         # None

Apply

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.