Modern Python Intermediate #2: typing in earnest — Generic, Protocol, TypedDict, Literal

4 min read

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

🚫 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 case 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.” 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].

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

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 allow isinstance checks, 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

However, 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” #

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("부족함")
    ...

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.

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": "커티스", "age": 30}
print(u["name"])   # name: str — type narrowed

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

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

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": "커티스"}                       # OK
u2: User = {"id": 1, "name": "커티스", "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

Autocomplete on the caller side is precise, and typos are caught at type-check 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 place, 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 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 / ✅ 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 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 #

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

  • Genericdef 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.abc is a standard Protocol set — Iterable, Sized, Mapping, etc.
  • @runtime_checkable for isinstance (signature isn’t verified)
  • TypedDict — declare a dict shape; fits JSON/external data
  • Optional keys with NotRequired; force required with Required
  • Literal — values as types; key for discriminated unions
  • Final — assigned once; declares constants
  • Replace Optional[X] with X | None; Any as a last resort; use Self for 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.

X