Contents
9 Chapter

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, None
  • list[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 #

🚫 Type too wide
def first(items: list) -> object:
    return items[0]

x = first([1, 2, 3])    # x: object  ← lost the int information

first([1,2,3]) clearly returns int, but with the signature object the editor loses that information. We need parameterization.

✅ Type parameter (3.12+)
def first[T](items: list[T]) -> T:
    return items[0]

x = first([1, 2, 3])      # x: int
y = first(["a", "b"])     # y: str

The def first[T] form is new syntax in Python 3.12 (PEP 695). It used to look like:

Old style — don't use in new code
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 #

Two or more
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 #

bound — upper bound
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).

Explicit union constraint
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 #

Generic class (3.12+)
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 | None

The 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).

Defining a Protocol
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 too

runtime_checkableisinstance-able at runtime #

By default Protocol is for static checks. To check with isinstance, add the decorator.

Runtime checks
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

print(isinstance(File(), Closable))   # True

But 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” #

Practical example — sized and iterable
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.

Common ABCs
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.

TypedDict basics
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 narrowed

TypedDict is just a dict at runtime. Instance checks treat it as a dict. Only type checkers see it.

Difference from dataclass #

dataclassTypedDict
Runtime formuser-defined class instanceregular dict
Methodsauto __init__, __repr__, __eq__none (only dict methods)
Accessu.nameu["name"]
Fitsinternal domain modelsJSON, 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 #

Optional keys
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"}     # OK

The 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:

Using Required
from typing import TypedDict, Required

class Config(TypedDict, total=False):
    timeout: int
    retries: int
    base_url: Required[str]    # only this required

Inheritance works too #

Inheritance
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.

Literal basics
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 allowed

Caller-side autocomplete is exact, and typos are caught at compile time.

Literal and union #

Multiple Literals
type Color = Literal["red", "green", "blue"]
type Mode = Literal["light", "dark", "auto"]

def render(color: Color, mode: Mode) -> str:
    ...

Combined with constants — Final #

Final
from typing import Final, Literal

DEFAULT_LEVEL: Final = "info"
# DEFAULT_LEVEL's type narrows to Literal["info"]

DEFAULT_LEVEL = "warn"   # ✗ Final — can't reassign

Final 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.

discriminated union
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 / ✅ new
# 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 #

Self (3.11+)
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 #

  1. Write a function with signature def last[T](items: list[T]) -> T | None:. Verify with pyright that last([1, 2, 3]) is inferred as int | None and last(["a", "b"]) as str | None.
  2. Define Drawable (def draw(self) -> str: ...) with Protocol, then write def render_all(items: list[Drawable]) -> list[str]:. Make two classes that only have a draw method (no inheritance), pass instance lists of both to render_all, and confirm it works.
  3. Build a discriminated union with TypedDict + Literal. Combine ClickEvent (type: Literal["click"], x: int, y: int) and KeyEvent (type: Literal["key"], code: str) into Event = ClickEvent | KeyEvent, and confirm that inside an if event["type"] == "click": branch, accessing event["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.abc is 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. Optional is dropped, Any is a last resort, Self is 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.

X