Advanced Typing: Generics, Protocol and TypedDict

Type variables let one function work with any type while keeping the relationship between input and output. Protocol describes a shape, and TypedDict describes a dictionary.

The problem generics solve

def first(items: list) -> object:
    return items[0]


value = first([1, 2, 3])
# A checker only knows this is `object`, so this is reported as an error:
# print(value + 1)
from typing import TypeVar

T = TypeVar("T")


def first(items: list[T]) -> T:
    """Whatever type the list holds, that is what comes back."""
    return items[0]


number = first([1, 2, 3])          # a checker knows this is int
text = first(["a", "b"])           # and this is str

A type variable is a placeholder linking the input type to the output type. It says "the same type, whatever it is", which is exactly what object cannot express.

# Python 3.12 has dedicated syntax and needs no TypeVar
# def first[T](items: list[T]) -> T:
#     return items[0]

Type variables in practice

from typing import TypeVar
from collections.abc import Callable, Iterable

T = TypeVar("T")
U = TypeVar("U")


def apply(func: Callable[[T], U], values: Iterable[T]) -> list[U]:
    """Takes Ts, applies a T to U function, returns Us."""
    return [func(value) for value in values]


lengths = apply(len, ["a", "bb", "ccc"])        # list[int]
uppers = apply(str.upper, ["a", "b"])           # list[str]


def swap(pair: tuple[T, U]) -> tuple[U, T]:
    a, b = pair
    return b, a


print(swap((1, "one")))                          # tuple[str, int]

Constrained and bounded type variables

from typing import TypeVar

# Constrained: only these exact types
Number = TypeVar("Number", int, float)


def add(a: Number, b: Number) -> Number:
    return a + b


print(add(1, 2))          # int
print(add(1.5, 2.5))      # float
# add(1, 2.5)             # a checker complains: they must be the same


# Bounded: this type or any subclass of it
from collections.abc import Sized

S = TypeVar("S", bound=Sized)


def longest(a: S, b: S) -> S:
    return a if len(a) >= len(b) else b


print(longest([1, 2], [1]))
print(longest("abc", "ab"))

Generic classes

from typing import Generic, TypeVar

T = TypeVar("T")


class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("pop from an empty stack")
        return self._items.pop()

    def peek(self) -> T:
        return self._items[-1]

    def __len__(self) -> int:
        return len(self._items)


numbers: Stack[int] = Stack()
numbers.push(1)
value = numbers.pop()              # a checker knows this is int

words: Stack[str] = Stack()
words.push("a")
# words.push(1)                    # reported as an error
from typing import Generic, TypeVar

K = TypeVar("K")
V = TypeVar("V")


class Cache(Generic[K, V]):
    def __init__(self, maximum: int = 100) -> None:
        self._data: dict[K, V] = {}
        self._maximum = maximum

    def get(self, key: K, default: V | None = None) -> V | None:
        return self._data.get(key, default)

    def put(self, key: K, value: V) -> None:
        if len(self._data) >= self._maximum:
            self._data.pop(next(iter(self._data)))
        self._data[key] = value


cache: Cache[str, list[int]] = Cache()
cache.put("scores", [1, 2, 3])

Protocol: describing a shape

from typing import Protocol


class Drawable(Protocol):
    def draw(self) -> str:
        ...


class Circle:                    # does NOT inherit from Drawable
    def draw(self) -> str:
        return "circle"


class Square:
    def draw(self) -> str:
        return "square"


def render(shape: Drawable) -> str:
    return shape.draw()


print(render(Circle()))
print(render(Square()))
# render(42)                     # a checker reports: int has no draw method

A Protocol is structural: a class matches it by having the right methods, with no inheritance and no registration. It is duck typing that a checker can verify, and it works on classes you did not write.

from typing import Protocol, runtime_checkable


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


class Connection:
    def close(self) -> None:
        print("closed")


print(isinstance(Connection(), Closeable))      # True, at runtime
print(isinstance(42, Closeable))                # False
from typing import Protocol


class SupportsComparison(Protocol):
    def __lt__(self, other: object) -> bool:
        ...


def maximum[T: SupportsComparison](items: list[T]) -> T:      # 3.12 syntax
    result = items[0]
    for item in items[1:]:
        if result < item:
            result = item
    return result
ABCProtocol
Requires inheritanceYesNo
Checked atInstantiationType check time
Works on foreign classesNoYes
Can supply shared codeYesNot usefully

TypedDict

from typing import TypedDict


class User(TypedDict):
    name: str
    age: int
    email: str


def greet(user: User) -> str:
    return f"Hello {user['name']}, aged {user['age']}"


meera: User = {"name": "Meera", "age": 27, "email": "m@example.com"}
print(greet(meera))

# A checker reports each of these:
# bad: User = {"name": "Meera"}                    # missing keys
# bad: User = {"name": "Meera", "age": "27", ...}  # wrong type
# print(meera["phone"])                            # unknown key

JSON arrives as dictionaries. TypedDict describes the expected shape so a checker can catch a misspelled key, which is otherwise found only at runtime.

from typing import TypedDict, NotRequired


class Config(TypedDict):
    host: str
    port: int
    timeout: NotRequired[int]          # optional key
    debug: NotRequired[bool]


settings: Config = {"host": "localhost", "port": 8080}
with_timeout: Config = {"host": "a", "port": 1, "timeout": 30}


class Address(TypedDict):
    city: str
    pin: str


class Person(TypedDict):
    name: str
    address: Address                   # nested


record: Person = {"name": "Meera", "address": {"city": "Pune", "pin": "411001"}}
print(record["address"]["city"])

Literal and Final

from typing import Literal, Final

Mode = Literal["read", "write", "append"]


def open_file(path: str, mode: Mode = "read") -> None:
    print(f"opening {path} in {mode} mode")


open_file("a.txt", "write")
# open_file("a.txt", "delete")     # a checker reports the invalid literal


MAX_RETRIES: Final = 3
API_VERSION: Final[str] = "v2"

# MAX_RETRIES = 5                  # a checker reports: cannot reassign a Final

Narrowing

def describe(value: int | str | list[int]) -> str:
    if isinstance(value, int):
        return f"the number {value + 1}"        # narrowed to int
    if isinstance(value, str):
        return value.upper()                    # narrowed to str
    return f"{len(value)} items"                # must be list[int]


def label(value: str | None) -> str:
    if value is None:
        return "unknown"
    return value.strip()                        # narrowed: not None here


def process(values: list[int] | None = None) -> int:
    if not values:
        return 0
    return sum(values)                          # narrowed to list[int]

A checker follows your control flow. Each test removes possibilities, so by the last branch the type is fully known and the checker can verify the operations on it.

from typing import assert_never


def handle(mode: Literal["read", "write"]) -> str:
    if mode == "read":
        return "reading"
    if mode == "write":
        return "writing"
    assert_never(mode)         # a checker errors here if a case was missed

Overloads

from typing import overload


@overload
def parse(value: str) -> str: ...
@overload
def parse(value: int) -> int: ...
@overload
def parse(value: list[str]) -> list[str]: ...


def parse(value):
    """The single real implementation."""
    if isinstance(value, list):
        return [v.strip() for v in value]
    if isinstance(value, str):
        return value.strip()
    return value


a = parse("  x  ")          # a checker knows: str
b = parse(5)                # int
c = parse([" a "])          # list[str]

Overloads describe several precise signatures for one function. Only the last definition runs; the rest exist purely for the checker.

Self and class methods

from typing import Self


class QueryBuilder:
    def __init__(self, table: str) -> None:
        self.table = table
        self.conditions: list[str] = []

    def where(self, condition: str) -> Self:      # Python 3.11+
        self.conditions.append(condition)
        return self

    def build(self) -> str:
        clause = " AND ".join(self.conditions)
        return f"SELECT * FROM {self.table}" + (f" WHERE {clause}" if clause else "")


class NotesQuery(QueryBuilder):
    def published(self) -> Self:
        return self.where("status = 'published'")


# Self means the chain keeps the subclass type
query = NotesQuery("notes").published().where("views > 10").build()
print(query)

Typing generators and context managers

from collections.abc import Iterator, Generator
from contextlib import contextmanager


def countdown(n: int) -> Iterator[int]:
    while n > 0:
        yield n
        n -= 1


def accumulator() -> Generator[int, int, str]:
    """Yields int, receives int, returns str."""
    total = 0
    while total < 100:
        received = yield total
        total += received
    return "done"


@contextmanager
def timed(label: str) -> Iterator[None]:
    import time
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {time.perf_counter() - start:.4f}s")

A fully typed module

from __future__ import annotations

from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from typing import Literal, Protocol, TypedDict

Status = Literal["draft", "published", "archived"]


class RawNote(TypedDict):
    """The shape of a note as it arrives from JSON."""
    title: str
    body: str
    status: Status
    tags: list[str]


class Storage(Protocol):
    def save(self, key: str, value: str) -> None: ...
    def load(self, key: str) -> str | None: ...


@dataclass
class Note:
    title: str
    body: str = ""
    status: Status = "draft"
    tags: list[str] = field(default_factory=list)

    @classmethod
    def from_raw(cls, raw: RawNote) -> Note:
        return cls(
            title=raw["title"],
            body=raw["body"],
            status=raw["status"],
            tags=list(raw["tags"]),
        )

    @property
    def is_visible(self) -> bool:
        return self.status == "published"


def published(notes: Iterable[Note]) -> Iterator[Note]:
    """Yield only the visible notes."""
    for note in notes:
        if note.is_visible:
            yield note


def save_all(notes: Iterable[Note], storage: Storage) -> int:
    """Save every note and return how many were written."""
    count = 0
    for note in notes:
        storage.save(note.title, note.body)
        count += 1
    return count


class MemoryStorage:
    """Matches Storage structurally, with no inheritance."""

    def __init__(self) -> None:
        self._data: dict[str, str] = {}

    def save(self, key: str, value: str) -> None:
        self._data[key] = value

    def load(self, key: str) -> str | None:
        return self._data.get(key)


raw: RawNote = {"title": "Typing", "body": "notes", "status": "published",
                "tags": ["python"]}
note = Note.from_raw(raw)
store = MemoryStorage()
print(save_all(published([note]), store))
print(store.load("Typing"))

Common mistakes

  • Using a bare TypeVar where a concrete type would be clearer.
  • Inheriting from a Protocol - it defeats the purpose, which is structural matching.
  • Forgetting @runtime_checkable and then calling isinstance on a protocol.
  • Using TypedDict for a fixed structure your own code creates; a dataclass is better.
  • Writing overloads whose signatures overlap ambiguously.
  • Adding types everywhere at once instead of at the boundaries first.
  • Believing any of this is enforced at runtime.

Best practices

  • Use a TypeVar when the return type depends on the argument type.
  • Use Protocol to describe a capability, especially for classes you do not own.
  • Use TypedDict for external JSON and dataclasses for your own structures.
  • Use Literal for fixed string options, or an Enum at runtime.
  • Let narrowing do the work; isinstance checks inform the checker.
  • Alias complicated types once and reuse the alias.

Practice

  1. Write a generic Queue class and use it with two different element types.
  2. Define a Protocol for anything with a read method and write a function accepting it.
  3. Describe an API response with TypedDict, including two optional keys.
  4. Write a function taking int | str | None and handle each case with narrowing.
  5. Add overloads to a function that returns a different type depending on a flag.

Conclusion

Generics keep the relationship between input and output types, Protocol describes shapes rather than ancestry, and TypedDict brings external dictionaries under checking. Together they cover almost everything a dynamic language needs to describe about itself.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Type Hints: The Basics

Annotations describe what a function expects and returns. Python does not enforce them, and that is the point: they are documentation a tool can check...

Read more
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.