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
- Type variables in practice
- Constrained and bounded type variables
- Generic classes
- Protocol: describing a shape
- TypedDict
- Literal and Final
- Narrowing
- Overloads
- Self and class methods
- Typing generators and context managers
- A fully typed module
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
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 strA 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 errorfrom 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 methodA 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)) # Falsefrom 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| ABC | Protocol | |
|---|---|---|
| Requires inheritance | Yes | No |
| Checked at | Instantiation | Type check time |
| Works on foreign classes | No | Yes |
| Can supply shared code | Yes | Not 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 keyJSON 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 FinalNarrowing
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 missedOverloads
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
TypeVarwhere a concrete type would be clearer. - Inheriting from a
Protocol- it defeats the purpose, which is structural matching. - Forgetting
@runtime_checkableand then callingisinstanceon a protocol. - Using
TypedDictfor 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
TypeVarwhen the return type depends on the argument type. - Use
Protocolto describe a capability, especially for classes you do not own. - Use
TypedDictfor external JSON and dataclasses for your own structures. - Use
Literalfor fixed string options, or anEnumat runtime. - Let narrowing do the work;
isinstancechecks inform the checker. - Alias complicated types once and reuse the alias.
Practice
- Write a generic
Queueclass and use it with two different element types. - Define a
Protocolfor anything with areadmethod and write a function accepting it. - Describe an API response with
TypedDict, including two optional keys. - Write a function taking
int | str | Noneand handle each case with narrowing. - 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.