Advanced Type Hints
📖 Concept
Python's type system has evolved dramatically since PEP 484 introduced type hints in Python 3.5. Modern Python (3.10+) offers a rich, expressive type system that catches bugs at development time through static analysis tools like mypy, pyright, and Pytype — without any runtime performance cost.
Core advanced typing constructs:
| Construct | Purpose | Example |
|---|---|---|
TypeVar |
Generic type parameter | T = TypeVar("T") |
Generic[T] |
Base for generic classes | class Box(Generic[T]) |
Protocol |
Structural subtyping (duck typing) | class Drawable(Protocol) |
TypeAlias |
Explicit type alias | Vector: TypeAlias = list[float] |
Literal |
Restrict to specific values | Literal["read", "write"] |
TypeGuard |
Custom type narrowing | def is_str(x) -> TypeGuard[str] |
overload |
Multiple signatures | Different return types per input |
ParamSpec |
Preserve callable signatures | Decorator typing |
Protocol vs ABC:
ABCuses nominal subtyping — classes must explicitly inherit from the ABCProtocoluses structural subtyping — any class with matching methods is compatible, no inheritance required (true duck typing for the type checker)
TypeVar constraints and bounds:
T = TypeVar("T")— unconstrained, any typeT = TypeVar("T", int, str)— constrained to exactlyintorstrT = TypeVar("T", bound=Comparable)— bounded, must be subtype ofComparable
Runtime type checking is possible with libraries like beartype, typeguard, and pydantic. While Python's type hints are ignored at runtime by default (def f(x: int) accepts any type), these libraries add runtime validation using __annotations__ and function introspection.
mypy is the reference static type checker. Key flags: --strict (all checks), --disallow-untyped-defs, --no-implicit-optional. Configuration via mypy.ini or pyproject.toml.
💻 Code Example
1# ============================================================2# TypeVar — generic functions3# ============================================================4from typing import (5 TypeVar, Generic, Protocol, TypeAlias, Literal,6 TypeGuard, overload, ParamSpec, Callable, Iterator,7 runtime_checkable, ClassVar, Final, Annotated8)9from collections.abc import Sequence, Mapping1011T = TypeVar("T")12K = TypeVar("K")13V = TypeVar("V")141516def first(items: Sequence[T]) -> T | None:17 """Return the first item of any sequence, preserving type."""18 return items[0] if items else None1920# mypy infers: first([1, 2, 3]) -> int | None21# mypy infers: first(["a", "b"]) -> str | None222324def merge_dicts(d1: dict[K, V], d2: dict[K, V]) -> dict[K, V]:25 """Merge two dicts with matching key/value types."""26 return {**d1, **d2}272829# Constrained TypeVar — only int or float30Number = TypeVar("Number", int, float)3132def add(a: Number, b: Number) -> Number:33 return a + b3435# add(1, 2) -> int36# add(1.0, 2.0) -> float37# add("a", "b") -> mypy error!383940# Bounded TypeVar41from typing import SupportsLt4243Sortable = TypeVar("Sortable", bound=SupportsLt)4445def min_value(items: list[Sortable]) -> Sortable:46 """Find minimum value in a list of comparable items."""47 result = items[0]48 for item in items[1:]:49 if item < result:50 result = item51 return result525354# ============================================================55# Generic classes56# ============================================================57class Stack(Generic[T]):58 """Type-safe stack implementation."""5960 def __init__(self) -> None:61 self._items: list[T] = []6263 def push(self, item: T) -> None:64 self._items.append(item)6566 def pop(self) -> T:67 if not self._items:68 raise IndexError("Stack is empty")69 return self._items.pop()7071 def peek(self) -> T:72 if not self._items:73 raise IndexError("Stack is empty")74 return self._items[-1]7576 def __len__(self) -> int:77 return len(self._items)7879 def __iter__(self) -> Iterator[T]:80 return reversed(self._items)818283# Type-safe usage84int_stack: Stack[int] = Stack()85int_stack.push(1)86int_stack.push(2)87# int_stack.push("oops") # mypy error: str is not int8889str_stack: Stack[str] = Stack()90str_stack.push("hello")91value: str = str_stack.pop() # mypy knows this is str929394# Multiple type parameters95class Result(Generic[T, K]):96 """Rust-inspired Result type for error handling."""9798 def __init__(self, value: T | None = None, error: K | None = None):99 self._value = value100 self._error = error101102 @classmethod103 def ok(cls, value: T) -> "Result[T, K]":104 return cls(value=value)105106 @classmethod107 def err(cls, error: K) -> "Result[T, K]":108 return cls(error=error)109110 def is_ok(self) -> bool:111 return self._error is None112113 def unwrap(self) -> T:114 if self._error is not None:115 raise ValueError(f"Called unwrap on error: {self._error}")116 return self._value # type: ignore117118119# ============================================================120# Protocol — structural subtyping (duck typing)121# ============================================================122@runtime_checkable123class Drawable(Protocol):124 """Any object with a draw() method is Drawable."""125126 def draw(self, canvas: "Canvas") -> None:127 ...128129 @property130 def bounds(self) -> tuple[int, int, int, int]:131 ...132133134class Canvas:135 def render(self, shape: Drawable) -> str:136 """Accepts ANY object with draw() and bounds — no inheritance!"""137 x, y, w, h = shape.bounds138 shape.draw(self)139 return f"Rendered at ({x},{y}) size {w}x{h}"140141142class Circle: # Does NOT inherit from Drawable143 def __init__(self, x: int, y: int, radius: int):144 self.x, self.y, self.radius = x, y, radius145146 def draw(self, canvas: Canvas) -> None:147 print(f"Drawing circle at ({self.x},{self.y}) r={self.radius}")148149 @property150 def bounds(self) -> tuple[int, int, int, int]:151 return (self.x - self.radius, self.y - self.radius,152 self.radius * 2, self.radius * 2)153154155# This works! Circle matches the Drawable protocol structurally156canvas = Canvas()157circle = Circle(100, 100, 50)158canvas.render(circle) # mypy: OK, Circle matches Drawable159print(isinstance(circle, Drawable)) # True (runtime_checkable)160161162# ============================================================163# TypeAlias and Literal164# ============================================================165# Explicit type aliases (Python 3.10+)166JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None167Headers: TypeAlias = dict[str, str]168Callback: TypeAlias = Callable[[str, int], bool]169170171# Literal types — restrict to specific values172LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]173HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]174175176def log(message: str, level: LogLevel = "INFO") -> None:177 print(f"[{level}] {message}")178179log("Server started", "INFO") # OK180# log("Error", "VERBOSE") # mypy error: not a valid Literal181182183def make_request(url: str, method: HttpMethod = "GET") -> dict:184 return {"url": url, "method": method}185186187# ============================================================188# @overload — multiple signatures189# ============================================================190@overload191def process(data: str) -> list[str]: ...192@overload193def process(data: list[int]) -> int: ...194@overload195def process(data: dict[str, int]) -> list[tuple[str, int]]: ...196197def process(data):198 """Process different data types with type-specific return types."""199 if isinstance(data, str):200 return data.split()201 elif isinstance(data, list):202 return sum(data)203 elif isinstance(data, dict):204 return list(data.items())205 raise TypeError(f"Unsupported type: {type(data)}")206207208# mypy infers the correct return type for each call:209words: list[str] = process("hello world")210total: int = process([1, 2, 3])211pairs: list[tuple[str, int]] = process({"a": 1, "b": 2})212213214# ============================================================215# TypeGuard — custom type narrowing216# ============================================================217def is_string_list(val: list[object]) -> TypeGuard[list[str]]:218 """Check if all elements in a list are strings."""219 return all(isinstance(item, str) for item in val)220221222def process_items(items: list[object]) -> str:223 if is_string_list(items):224 # mypy now knows items is list[str]225 return ", ".join(items) # No error!226 return str(items)227228229# ============================================================230# ParamSpec — preserving callable signatures in decorators231# ============================================================232P = ParamSpec("P")233R = TypeVar("R")234235236def with_logging(func: Callable[P, R]) -> Callable[P, R]:237 """Decorator that preserves the original function's type signature."""238 import functools239240 @functools.wraps(func)241 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:242 print(f"Calling {func.__name__}")243 result = func(*args, **kwargs)244 print(f"{func.__name__} returned {result!r}")245 return result246 return wrapper247248249@with_logging250def greet(name: str, greeting: str = "Hello") -> str:251 return f"{greeting}, {name}!"252253254# mypy sees: greet(name: str, greeting: str = "Hello") -> str255# The decorator preserves the EXACT signature256result = greet("Alice") # OK257# greet(123) # mypy error: int is not str258259260# ============================================================261# Final, ClassVar, and Annotated262# ============================================================263class AppSettings:264 MAX_RETRIES: Final = 3 # Cannot be reassigned or overridden265 VERSION: ClassVar[str] = "1.0.0" # Class-level only, not on instances266267 # Annotated: attach metadata to types (used by Pydantic, FastAPI, etc.)268 port: Annotated[int, "Must be between 1 and 65535"]269 host: Annotated[str, "Hostname or IP address"]270271 def __init__(self, host: str = "localhost", port: int = 8080):272 self.host = host273 self.port = port
🏋️ Practice Exercise
Exercises:
Create a generic
Repository[T]class with methodsadd(item: T),get(id: str) -> T | None,list_all() -> list[T], anddelete(id: str) -> bool. Instantiate it asRepository[User]andRepository[Product]and verify type safety with mypy.Define a
ComparableProtocol with__lt__and__eq__methods. Write a genericbinary_search(items: list[T], target: T) -> int | Nonefunction bounded byComparable. Verify it works with bothintand custom classes without inheritance.Use
@overloadto type aserialize()function that returnsstrwhen givenformat="json",byteswhen givenformat="binary", anddictwhen givenformat="dict". Verify each overload with mypy.Write a decorator using
ParamSpecandTypeVarthat adds retry logic to any function while preserving its exact type signature. Verify that mypy correctly reports errors when the decorated function is called with wrong argument types.Create a type-safe event system:
EventBuswithsubscribe(event_type: type[T], handler: Callable[[T], None])andpublish(event: T). UseGenericandProtocolto ensure handlers receive the correct event type.Run mypy with
--stricton a 50-line program that uses generics, protocols, and overloads. Fix all reported type errors. Document three categories of errors mypy caught that would have been runtime bugs.
⚠️ Common Mistakes
Confusing
TypeVarconstraints with bounds.T = TypeVar('T', int, str)means T is EXACTLYintorstr.T = TypeVar('T', bound=Number)means T is any SUBTYPE ofNumber. Constraints create a union; bounds create a hierarchy.Using
Protocolwithout@runtime_checkableand then attemptingisinstance()checks. By default, Protocols are for static checking only. Add@runtime_checkableto enableisinstance(), but note it only checks method existence, not signatures.Forgetting that type hints are NOT enforced at runtime by default.
def f(x: int) -> str: return xruns without error. Type hints are metadata for tools like mypy. Usebeartypeortypeguardfor runtime enforcement.Using mutable default arguments in typed signatures.
def f(items: list[int] = [])has the classic mutable default bug AND a typing issue. UseNoneas default:def f(items: list[int] | None = None)and create the list inside the function.Mixing up
type[X]andXin annotations.def f(cls: type[Animal])accepts the Animal class itself (or subclasses);def f(obj: Animal)accepts instances. Using the wrong one causes subtle type errors that mypy catches but are easy to miss.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Advanced Type Hints