Modern Type Hints and Static Analysis
Modern Type Hints and Static Analysis
After reading this chapter, you will master Python’s modern static type system, implement generic types with typing.Generic, define structural interfaces with typing.Protocol (duck typing formalized), leverage Literal, Union (|), and Self, and configure strict static analysis with mypy and pyright.
Mental model
Python type hints (PEP 484, PEP 604, PEP 695) do not alter runtime execution or impose execution overhead. They provide formal machine-readable contracts analyzed by static type checkers before code is deployed:
Development & CI Workflow:
Python Source Code (with type hints)
│
├─ 1. Static Type Checker (mypy / pyright) ──▶ Reports bugs, mismatches, None hazards
│
└─ 2. CPython Runtime VM ────────────────────▶ Erases types, executes at full speed
Nominal Subtyping vs Structural Subtyping (Protocol)
- Nominal Subtyping (
class Sub(Parent)): Requires an explicit inheritance declaration. - Structural Subtyping (
Protocol): Formalizes Python’s “duck typing” philosophy: if an object has the required methods and attributes, it satisfies the type without needing to inherit from the protocol.
Minimal example
Save as typing_protocols.py:
# typing_protocols.py
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
"""Structural protocol: Any class with a render() -> str method satisfies this."""
def render(self) -> str:
...
class ServiceCard:
"""Implements Renderable implicitly WITHOUT inheriting from it."""
def __init__(self, name: str, port: int) -> None:
self.name = name
self.port = port
def render(self) -> str:
return f"<ServiceCard: {self.name}:{self.port}>"
def display_dashboard_item(item: Renderable) -> None:
print("Dashboard Render:", item.render())
def main() -> None:
card = ServiceCard("auth-service", 8080)
# Static type checkers and runtime_checkable verify compatibility
display_dashboard_item(card)
print(f"Is ServiceCard an instance of Renderable? {isinstance(card, Renderable)}")
if __name__ == "__main__":
main()Run via uv run python typing_protocols.py:
Dashboard Render: <ServiceCard: auth-service:8080>
Is ServiceCard an instance of Renderable? True
Worked examples
Case 1: Generic Data Containers (typing.Generic)
Generics allow functions and classes to operate over arbitrary types while preserving strict type safety:
# generic_repository.py
from typing import Generic, TypeVar
T = TypeVar("T")
class KeyValueCache(Generic[T]):
def __init__(self) -> None:
self._store: dict[str, T] = {}
def put(self, key: str, value: T) -> None:
self._store[key] = value
def get(self, key: str) -> T | None:
return self._store.get(key)
if __name__ == "__main__":
# Cache parameterized for integers
int_cache: KeyValueCache[int] = KeyValueCache()
int_cache.put("cpu_max", 95)
print("Retrieved integer metric:", int_cache.get("cpu_max"))
# Cache parameterized for strings
str_cache: KeyValueCache[str] = KeyValueCache()
str_cache.put("active_cluster", "us-east-1a")
print("Retrieved string metric :", str_cache.get("active_cluster"))Run:
uv run python generic_repository.pyOutput:
Retrieved integer metric: 95
Retrieved string metric : us-east-1a
Case 2: Fluent Method Chaining with typing.Self
When implementing builder patterns, methods returning self should be typed with typing.Self (PEP 673) so that subclasses automatically preserve their own derived return type:
# query_builder.py
from typing import Self
class QueryBuilder:
def __init__(self) -> None:
self._query_parts: list[str] = []
def select(self, fields: str) -> Self:
self._query_parts.append(f"SELECT {fields}")
return self
def from_table(self, table: str) -> Self:
self._query_parts.append(f"FROM {table}")
return self
def limit(self, count: int) -> Self:
self._query_parts.append(f"LIMIT {count}")
return self
def build(self) -> str:
return " ".join(self._query_parts)
if __name__ == "__main__":
sql = QueryBuilder().select("id, host, status").from_table("nodes").limit(10).build()
print("Constructed SQL query:")
print(sql)Run:
uv run python query_builder.pyOutput:
Constructed SQL query:
SELECT id, host, status FROM nodes LIMIT 10
Pitfalls
Pitfall 1: Confusing Type[T] with T
obj: ServerNode: Expects an instance ofServerNode.cls: type[ServerNode]: Expects the class itself (for factory functions or constructor reflection).
Pitfall 2: Using Union Instead of the Modern | Operator
In modern Python (PEP 604), write int | str | None instead of Union[int, str, Optional[None]]. The pipe syntax is native and evaluated without importing Union.
Exercises
- Define a
ProtocolnamedSerializablewith a methodto_dict() -> dict[str, object]. Implement two unrelated classes that satisfy this protocol. - Write a generic function
first_or_default(items: list[T], default: T) -> Tthat returns the first item or a default fallback. - Use
Literal["pending", "running", "completed"]to restrict a function’s status parameter to exact allowed strings. - Configure a
[tool.mypy]section in apyproject.tomlenablingstrict = trueand verify code compliance.
Further reading
- PEP 484: Type Hints.
- PEP 544: Protocols: Structural subtyping (static duck typing).
- PEP 604: Allow writing union types as X | Y.
- PEP 673: Self Type.