Advanced Type Hints

0/5 in this phase0/54 across the roadmap

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

  • ABC uses nominal subtyping — classes must explicitly inherit from the ABC
  • Protocol uses 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 type
  • T = TypeVar("T", int, str) — constrained to exactly int or str
  • T = TypeVar("T", bound=Comparable) — bounded, must be subtype of Comparable

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

codeTap to expand ⛶
1# ============================================================
2# TypeVar — generic functions
3# ============================================================
4from typing import (
5 TypeVar, Generic, Protocol, TypeAlias, Literal,
6 TypeGuard, overload, ParamSpec, Callable, Iterator,
7 runtime_checkable, ClassVar, Final, Annotated
8)
9from collections.abc import Sequence, Mapping
10
11T = TypeVar("T")
12K = TypeVar("K")
13V = TypeVar("V")
14
15
16def first(items: Sequence[T]) -> T | None:
17 """Return the first item of any sequence, preserving type."""
18 return items[0] if items else None
19
20# mypy infers: first([1, 2, 3]) -> int | None
21# mypy infers: first(["a", "b"]) -> str | None
22
23
24def 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}
27
28
29# Constrained TypeVar — only int or float
30Number = TypeVar("Number", int, float)
31
32def add(a: Number, b: Number) -> Number:
33 return a + b
34
35# add(1, 2) -> int
36# add(1.0, 2.0) -> float
37# add("a", "b") -> mypy error!
38
39
40# Bounded TypeVar
41from typing import SupportsLt
42
43Sortable = TypeVar("Sortable", bound=SupportsLt)
44
45def 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 = item
51 return result
52
53
54# ============================================================
55# Generic classes
56# ============================================================
57class Stack(Generic[T]):
58 """Type-safe stack implementation."""
59
60 def __init__(self) -> None:
61 self._items: list[T] = []
62
63 def push(self, item: T) -> None:
64 self._items.append(item)
65
66 def pop(self) -> T:
67 if not self._items:
68 raise IndexError("Stack is empty")
69 return self._items.pop()
70
71 def peek(self) -> T:
72 if not self._items:
73 raise IndexError("Stack is empty")
74 return self._items[-1]
75
76 def __len__(self) -> int:
77 return len(self._items)
78
79 def __iter__(self) -> Iterator[T]:
80 return reversed(self._items)
81
82
83# Type-safe usage
84int_stack: Stack[int] = Stack()
85int_stack.push(1)
86int_stack.push(2)
87# int_stack.push("oops") # mypy error: str is not int
88
89str_stack: Stack[str] = Stack()
90str_stack.push("hello")
91value: str = str_stack.pop() # mypy knows this is str
92
93
94# Multiple type parameters
95class Result(Generic[T, K]):
96 """Rust-inspired Result type for error handling."""
97
98 def __init__(self, value: T | None = None, error: K | None = None):
99 self._value = value
100 self._error = error
101
102 @classmethod
103 def ok(cls, value: T) -> "Result[T, K]":
104 return cls(value=value)
105
106 @classmethod
107 def err(cls, error: K) -> "Result[T, K]":
108 return cls(error=error)
109
110 def is_ok(self) -> bool:
111 return self._error is None
112
113 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: ignore
117
118
119# ============================================================
120# Protocol — structural subtyping (duck typing)
121# ============================================================
122@runtime_checkable
123class Drawable(Protocol):
124 """Any object with a draw() method is Drawable."""
125
126 def draw(self, canvas: "Canvas") -> None:
127 ...
128
129 @property
130 def bounds(self) -> tuple[int, int, int, int]:
131 ...
132
133
134class Canvas:
135 def render(self, shape: Drawable) -> str:
136 """Accepts ANY object with draw() and bounds — no inheritance!"""
137 x, y, w, h = shape.bounds
138 shape.draw(self)
139 return f"Rendered at ({x},{y}) size {w}x{h}"
140
141
142class Circle: # Does NOT inherit from Drawable
143 def __init__(self, x: int, y: int, radius: int):
144 self.x, self.y, self.radius = x, y, radius
145
146 def draw(self, canvas: Canvas) -> None:
147 print(f"Drawing circle at ({self.x},{self.y}) r={self.radius}")
148
149 @property
150 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)
153
154
155# This works! Circle matches the Drawable protocol structurally
156canvas = Canvas()
157circle = Circle(100, 100, 50)
158canvas.render(circle) # mypy: OK, Circle matches Drawable
159print(isinstance(circle, Drawable)) # True (runtime_checkable)
160
161
162# ============================================================
163# TypeAlias and Literal
164# ============================================================
165# Explicit type aliases (Python 3.10+)
166JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
167Headers: TypeAlias = dict[str, str]
168Callback: TypeAlias = Callable[[str, int], bool]
169
170
171# Literal types — restrict to specific values
172LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
173HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
174
175
176def log(message: str, level: LogLevel = "INFO") -> None:
177 print(f"[{level}] {message}")
178
179log("Server started", "INFO") # OK
180# log("Error", "VERBOSE") # mypy error: not a valid Literal
181
182
183def make_request(url: str, method: HttpMethod = "GET") -> dict:
184 return {"url": url, "method": method}
185
186
187# ============================================================
188# @overload — multiple signatures
189# ============================================================
190@overload
191def process(data: str) -> list[str]: ...
192@overload
193def process(data: list[int]) -> int: ...
194@overload
195def process(data: dict[str, int]) -> list[tuple[str, int]]: ...
196
197def 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)}")
206
207
208# 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})
212
213
214# ============================================================
215# TypeGuard — custom type narrowing
216# ============================================================
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)
220
221
222def 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)
227
228
229# ============================================================
230# ParamSpec — preserving callable signatures in decorators
231# ============================================================
232P = ParamSpec("P")
233R = TypeVar("R")
234
235
236def with_logging(func: Callable[P, R]) -> Callable[P, R]:
237 """Decorator that preserves the original function's type signature."""
238 import functools
239
240 @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 result
246 return wrapper
247
248
249@with_logging
250def greet(name: str, greeting: str = "Hello") -> str:
251 return f"{greeting}, {name}!"
252
253
254# mypy sees: greet(name: str, greeting: str = "Hello") -> str
255# The decorator preserves the EXACT signature
256result = greet("Alice") # OK
257# greet(123) # mypy error: int is not str
258
259
260# ============================================================
261# Final, ClassVar, and Annotated
262# ============================================================
263class AppSettings:
264 MAX_RETRIES: Final = 3 # Cannot be reassigned or overridden
265 VERSION: ClassVar[str] = "1.0.0" # Class-level only, not on instances
266
267 # 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"]
270
271 def __init__(self, host: str = "localhost", port: int = 8080):
272 self.host = host
273 self.port = port

🏋️ Practice Exercise

Exercises:

  1. Create a generic Repository[T] class with methods add(item: T), get(id: str) -> T | None, list_all() -> list[T], and delete(id: str) -> bool. Instantiate it as Repository[User] and Repository[Product] and verify type safety with mypy.

  2. Define a Comparable Protocol with __lt__ and __eq__ methods. Write a generic binary_search(items: list[T], target: T) -> int | None function bounded by Comparable. Verify it works with both int and custom classes without inheritance.

  3. Use @overload to type a serialize() function that returns str when given format="json", bytes when given format="binary", and dict when given format="dict". Verify each overload with mypy.

  4. Write a decorator using ParamSpec and TypeVar that 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.

  5. Create a type-safe event system: EventBus with subscribe(event_type: type[T], handler: Callable[[T], None]) and publish(event: T). Use Generic and Protocol to ensure handlers receive the correct event type.

  6. Run mypy with --strict on 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 TypeVar constraints with bounds. T = TypeVar('T', int, str) means T is EXACTLY int or str. T = TypeVar('T', bound=Number) means T is any SUBTYPE of Number. Constraints create a union; bounds create a hierarchy.

  • Using Protocol without @runtime_checkable and then attempting isinstance() checks. By default, Protocols are for static checking only. Add @runtime_checkable to enable isinstance(), 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 x runs without error. Type hints are metadata for tools like mypy. Use beartype or typeguard for runtime enforcement.

  • Using mutable default arguments in typed signatures. def f(items: list[int] = []) has the classic mutable default bug AND a typing issue. Use None as default: def f(items: list[int] | None = None) and create the list inside the function.

  • Mixing up type[X] and X in 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