Modern Python Intermediate #2: typing in earnest — Generic, Protocol, TypedDict, Literal
If #1 dataclass and __slots__ gave us tools for concise data shapes, this post is about tools for expressing those shapes more precisely and powerfully. Four headline weapons of the typing module — Generic, Protocol, TypedDict, Literal.
Starting point — what was basics? #
In Basics #2:
int,str,bool,Nonelist[int],dict[str, int]int | None(union shorthand)type Alias = int(type alias)Callable[[int, int], int]
We had those. Next up: types whose parameters the caller fills in.
Generic — type parameters #
When you want a function or class that has the same structure but works with different inner types.
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 case 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.” 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].
Protocol — duck typing as a type #
The Python idiom: “if it walks like a duck and quacks like a duck, it’s a duck.” What matters isn’t 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 declare implements. 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 allow isinstance checks, add the decorator.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
print(isinstance(File(), Closable)) # TrueHowever, isinstance only checks method names — it doesn’t verify signatures. Static type checkers do that properly.
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("부족함")
...The function only needs __len__. Typing the parameter as “something with len” instead of list is therefore more accurate and more flexible — lists, tuples, dicts, and 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": "커티스", "age": 30}
print(u["name"]) # name: str — type narrowedTypedDict is just a plain dict at runtime. Instance checks treat it as a dict. Only type checkers see the declared shape.
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 code that takes an API response and works with it as a dict, TypedDict is a natural fit. 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": "커티스"} # OK
u2: User = {"id": 1, "name": "커티스", "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 allowedAutocomplete on the caller side is precise, and typos are caught at type-check 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 place, 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 type to narrow automatically. A pattern that pairs nicely with match-case from Basics #3.
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 effectively turns off type checking for that value. Only use it as a last resort; first consider whether a narrower type like object, Unknown (pyright), or unknown would 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 same class it was called on.” It pairs nicely with the builder pattern. The old style was -> "Builder" (a forward reference string), which wasn’t accurate when called via a subclass. Self solves that.
Wrap-up #
Tools this post covered:
- Generic —
def fn[T](...),class Stack[T]:(3.12+) — type parameters - Constraints:
[T: HasLength](bound),[T: (int, float)](constraints) - Protocol — duck typing as a type; no explicit implements needed
collections.abcis a standard Protocol set —Iterable,Sized,Mapping, etc.@runtime_checkablefor isinstance (signature isn’t verified)- TypedDict — declare a dict shape; fits JSON/external data
- Optional keys with
NotRequired; force required withRequired - Literal — values as types; key for discriminated unions
Final— assigned once; declares constants- Replace
Optional[X]withX | None;Anyas a last resort; useSelffor builder patterns
In the next post (#3 Context managers) we cover the standard tool for resource management — every pattern of the with statement and contextlib.