typing in earnest — Generic, Protocol, TypedDict, Literal
The next step from basics type hints — Generic for parameterizing types, Protocol for precise duck typing, TypedDict for dict shapes, and Literal for narrow unions.
If Chapter 8 dataclass and __slots__ gave us tools for compact data shapes, this chapter is about tools for expressing those shapes more precisely and powerfully. The four headline tools of the typing module are Generic, Protocol, TypedDict, and Literal.
These four tools recur throughout the rest of the book. Protocol sits on top of Chapter 15 magic methods in depth and protocols, and Generic leads into Chapter 20 typing advanced — Variance, ParamSpec, Self, overload. The TypedDict + Literal combination reappears in Part 4’s FastAPI as the discriminated union of Chapter 24 Pydantic v2 in depth.
Starting point — what was basics? #
In Chapter 2 variables, basic types, and type hints we got this far:
int,str,bool,Nonelist[int],dict[str, int]int | None(union shorthand)type Alias = int(type alias)Callable[[int, int], int]
Next up — starting with types whose parameters the user fills in.
Generic — type parameters #
When you want to write a function/class with the same shape but a different inner type.
Function — return the input type as-is #
def first(items: list) -> object:
return items[0]
x = first([1, 2, 3]) # x: object ← lost the int informationfirst([1,2,3]) clearly returns int, but with the signature object the editor loses that information. We need parameterization.
def first[T](items: list[T]) -> T:
return items[0]
x = first([1, 2, 3]) # x: int
y = first(["a", "b"]) # y: strThe def first[T] form is new syntax in Python 3.12 (PEP 695). It used to look like:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]New code unifies on writing [T] directly next to the function/method.
Multiple type parameters #
def pair[K, V](key: K, value: V) -> tuple[K, V]:
return (key, value)
p = pair("name", 42) # tuple[str, int]Type constraints — bound / explicit limits
#
class HasLength:
def __len__(self) -> int: ...
def shortest[T: HasLength](items: list[T]) -> T:
return min(items, key=len)[T: HasLength] is the constraint “T must be a subtype of HasLength.” Actually, Protocol fits this spot better (covered below).
def add[T: (int, float)](a: T, b: T) -> T:
return a + b
add(1, 2) # int
add(1.0, 2.0) # float
add(1, 2.0) # ✗ T can't be decided as one type[T: (int, float)] means “T is either int or float.” These are called generic constraints.
Class — Generic class #
class Stack[T]:
def __init__(self):
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
s.push(2)
x = s.pop() # x: int | NoneThe role of generic classes is filling in T at the use site, like Stack[int]. Topics like variance (covariant / contravariant / invariant) come in Chapter 20 typing advanced.
Protocol — duck typing as a type #
The Python idiom is “not which class an object is an instance of, but which methods / attributes it has.” Expressing this as a static type is what Protocol does.
Not Java/C#’s interface #
Java interfaces require you to explicitly write implements to be that interface. Python Protocols don’t. Match the shape and the Protocol is satisfied (structural typing).
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
# What is Closable?
def safe_close(resource: Closable) -> None:
resource.close()
# The function above accepts any object with a .close().
# No explicit inheritance needed.
class File:
def close(self) -> None:
print("file closed")
class Connection:
def close(self) -> None:
print("connection closed")
safe_close(File()) # OK — File satisfies Closable
safe_close(Connection()) # OK — Connection tooruntime_checkable — isinstance-able at runtime
#
By default Protocol is for static checks. To check with isinstance, add the decorator.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
print(isinstance(File(), Closable)) # TrueBut isinstance only checks method names — it doesn’t look at signatures. Static type checkers do that better.
The real value of Protocol — “promise only what you need” #
from typing import Protocol
class Sized(Protocol):
def __len__(self) -> int: ...
def first_n[T](items: Sized, n: int) -> ...:
if len(items) < n:
raise ValueError("not enough")
...The function only needs __len__. So it’s more accurate and flexible to type the parameter as “something with len” instead of “list.” Lists / tuples / dicts / user-defined classes all pass.
Protocols already in collections.abc
#
The standard library already has commonly-used Protocols.
from collections.abc import (
Iterable, Iterator, Sized, Container,
Mapping, Sequence, Callable, Hashable
)
def process(items: Iterable[int]) -> None:
for x in items:
...
def lookup(m: Mapping[str, int], key: str) -> int | None:
return m.get(key)collections.abc is abstract base classes but also acts like Protocol (@runtime_checkable-friendly), so standard shapes can be pulled from here. Define your own Protocol only when no standard shape matches.
TypedDict — dict shape as a type #
When you handle JSON-like data as dicts and want to declare which keys and value types belong.
from typing import TypedDict
class User(TypedDict):
id: int
name: str
age: int
u: User = {"id": 1, "name": "Curtis", "age": 30}
print(u["name"]) # name: str — type narrowedTypedDict is just a dict at runtime. Instance checks treat it as a dict. Only type checkers see it.
Difference from dataclass #
| dataclass | TypedDict | |
|---|---|---|
| Runtime form | user-defined class instance | regular dict |
| Methods | auto __init__, __repr__, __eq__ | none (only dict methods) |
| Access | u.name | u["name"] |
| Fits | internal domain models | JSON, external API responses |
For places where you take an API response and handle it as a dict, TypedDict fits. No conversion cost — just declare that the incoming dict has that shape.
Optional keys — total=False / NotRequired
#
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int # required
name: str # required
nickname: NotRequired[str] # optional
u1: User = {"id": 1, "name": "Curtis"} # OK
u2: User = {"id": 1, "name": "Curtis", "nickname": "C"} # OKThe old class-level total=False makes every key optional, so it’s rarely used. New code uses NotRequired as the standard.
Conversely, to make some keys required in an otherwise all-optional dict, use Required:
from typing import TypedDict, Required
class Config(TypedDict, total=False):
timeout: int
retries: int
base_url: Required[str] # only this requiredInheritance works too #
class BaseUser(TypedDict):
id: int
name: str
class AdminUser(BaseUser):
permissions: list[str]AdminUser has three keys: id, name, permissions.
Literal — narrow union #
The value itself as a type. When you want to allow only specific strings/numbers.
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
...
set_log_level("info") # OK
set_log_level("trace") # ✗ not in the four allowedCaller-side autocomplete is exact, and typos are caught at compile time.
Literal and union #
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]
def render(color: Color, mode: Mode) -> str:
...Combined with constants — Final
#
from typing import Final, Literal
DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL's type narrows to Literal["info"]
DEFAULT_LEVEL = "warn" # ✗ Final — can't reassignFinal declares “this variable is assigned only once.” Common for module constants.
Discriminated union — deterministic branching #
When dicts of different shapes arrive at one spot, identify “which shape” by a single key.
from typing import Literal, TypedDict
class ClickEvent(TypedDict):
type: Literal["click"]
x: int
y: int
class KeyEvent(TypedDict):
type: Literal["key"]
code: str
type Event = ClickEvent | KeyEvent
def handle(event: Event) -> None:
if event["type"] == "click":
# event is narrowed to ClickEvent here
print(event["x"], event["y"])
else:
# KeyEvent here
print(event["code"])The type checker uses the Literal value of the type key to narrow automatically. It pairs nicely with match-case from Chapter 3 control flow, and we revisit it in Chapter 13 pattern matching in depth.
Other commonly used tools #
Optional is no longer used
#
# Old
from typing import Optional
def find(id: int) -> Optional[str]: ...
# New — always this
def find(id: int) -> str | None: ...Any is a last resort
#
Any declares “I’m turning off type checking.” Only use it when you really don’t know; first consider whether a narrower type like object / Unknown (pyright) / unknown could work.
Self — return type of “this class” in a method
#
from typing import Self
class Builder:
def __init__(self):
self.items: list[str] = []
def add(self, item: str) -> Self:
self.items.append(item)
return self-> Self means “this method returns an instance of the class it’s called on.” Pairs nicely with the builder pattern. The old style was -> "Builder" (forward reference), which wasn’t accurate when called via a subclass. Self solves it. Chapter 20 typing advanced covers it in more detail.
Exercises #
- Write a function with signature
def last[T](items: list[T]) -> T | None:. Verify with pyright thatlast([1, 2, 3])is inferred asint | Noneandlast(["a", "b"])asstr | None. - Define
Drawable(def draw(self) -> str: ...) withProtocol, then writedef render_all(items: list[Drawable]) -> list[str]:. Make two classes that only have adrawmethod (no inheritance), pass instance lists of both torender_all, and confirm it works. - Build a discriminated union with
TypedDict+Literal. CombineClickEvent(type: Literal["click"],x: int,y: int) andKeyEvent(type: Literal["key"],code: str) intoEvent = ClickEvent | KeyEvent, and confirm that inside anif event["type"] == "click":branch, accessingevent["x"]produces no type error.
In one line: Generic parameterizes types with
def fn[T](...)/class Stack[T]:. Protocol is a shape-based promise with no explicit implements —collections.abcis the standard set. TypedDict declares dict shapes; great for JSON / external data (NotRequired/Required). Literal narrows values into types and is the core of discriminated unions.Optionalis dropped,Anyis a last resort,Selfis for builder patterns.
Next chapter #
In Chapter 10 context managers (with, contextlib) we cover the standard tool for resource management — every pattern of the with statement and contextlib. It is a safer and more readable way to express the cleanup code we wrote with finally in Chapter 6 errors and exceptions.