Python Type Hints in Practice: mypy, Protocols, and TypeVar
January 15, 2026 · 6 min read
Python's type system has matured significantly. Type hints are no longer just documentation — with mypy, pyright, or Pylance, they catch real bugs before runtime. But most codebases use only basic annotations. Protocols, TypeVar, ParamSpec, and the newer typing features unlock structural typing and generic programming that makes Python code both safer and more expressive.
Basic Annotations
Start with the fundamentals:
from typing import Optional, Union
from collections.abc import Sequence
# Built-in generics (Python 3.10+, use typing.List etc for older)
def process_items(items: list[str]) -> dict[str, int]:
return {item: len(item) for item in items}
# Optional (same as T | None in 3.10+)
def find_user(user_id: int) -> Optional[dict]:
...
# Union types
def parse_value(value: str | int | float) -> float:
return float(value)
# Callable
from collections.abc import Callable
def apply_transform(
data: list[int],
transform: Callable[[int], int]
) -> list[int]:
return [transform(x) for x in data]
# Sequences (more general than list)
def sum_values(values: Sequence[float]) -> float:
return sum(values)TypeVar for Generic Functions
TypeVar lets you write functions that work with multiple types while preserving type information:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
return items[0] if items else None
result = first([1, 2, 3]) # mypy knows this is int | None
name = first(["alice", "bob"]) # mypy knows this is str | NoneBound TypeVars constrain to subtypes:
from typing import TypeVar
class SupportsLessThan:
def __lt__(self, other) -> bool: ...
Comparable = TypeVar("Comparable", bound=SupportsLessThan)
def maximum(items: list[Comparable]) -> Comparable:
return max(items)For class hierarchies:
from typing import TypeVar
from myapp.models import BaseModel
ModelT = TypeVar("ModelT", bound=BaseModel)
class Repository[ModelT]:
def __init__(self, model_class: type[ModelT]):
self.model_class = model_class
async def get_by_id(self, id: int) -> ModelT | None:
...
async def create(self, **kwargs) -> ModelT:
...Protocols: Structural Typing
Protocols define interfaces through structure, not inheritance. If an object has the required methods, it satisfies the Protocol — no explicit inheritance needed:
from typing import Protocol, runtime_checkable
@runtime_checkable # Enables isinstance() checks
class Serializable(Protocol):
def to_dict(self) -> dict: ...
def to_json(self) -> str: ...
class Article:
def __init__(self, title: str, content: str):
self.title = title
self.content = content
def to_dict(self) -> dict:
return {"title": self.title, "content": self.content}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
def export(obj: Serializable) -> bytes:
return obj.to_json().encode()
# Article satisfies Serializable without inheriting from it
export(Article("Test", "Content")) # ✓ mypy is happyProtocols are ideal for dependency injection:
from typing import Protocol
class EmailSender(Protocol):
async def send(self, to: str, subject: str, body: str) -> None: ...
class ArticleService:
def __init__(self, email_sender: EmailSender):
self._email = email_sender
async def publish(self, article) -> None:
article.published = True
await self._email.send(
to=article.author.email,
subject="Your article was published",
body=f"'{article.title}' is now live."
)
# Both work — no common base class needed
class SmtpEmailSender:
async def send(self, to: str, subject: str, body: str) -> None: ...
class SendGridEmailSender:
async def send(self, to: str, subject: str, body: str) -> None: ...
class MockEmailSender: # For tests
sent_emails: list[dict] = []
async def send(self, to: str, subject: str, body: str) -> None:
self.sent_emails.append({"to": to, "subject": subject, "body": body})ParamSpec: Preserving Decorator Signatures
ParamSpec preserves the parameter types of wrapped functions in decorators:
from typing import TypeVar, Callable
from typing import ParamSpec
import functools
import time
P = ParamSpec("P")
R = TypeVar("R")
def timing(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timing
def process_data(items: list[int], multiplier: float = 1.0) -> list[float]:
return [x * multiplier for x in items]
# mypy knows process_data still takes (list[int], multiplier=float)
result = process_data([1, 2, 3], multiplier=2.5) # ✓
result = process_data("wrong") # ✗ mypy catches thisWithout ParamSpec, decorators would lose parameter type information and mypy would accept any arguments.
Literal and TypedDict
Literal restricts values to specific constants:
from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
def log(level: LogLevel, message: str) -> None:
print(f"[{level}] {message}")
log("INFO", "Server started") # ✓
log("VERBOSE", "Too detailed") # ✗ mypy errorTypedDict types dictionaries with known keys:
from typing import TypedDict, NotRequired
class ArticleDict(TypedDict):
id: int
title: str
content: str
published: bool
tags: list[str]
author_email: NotRequired[str] # Optional key
def render_article(article: ArticleDict) -> str:
return f"<h1>{article['title']}</h1><p>{article['content']}</p>"
# mypy catches missing required keys
render_article({"id": 1, "title": "Test"}) # ✗ missing required fieldsOverload for Multiple Return Types
@overload defines multiple signatures for a single function:
from typing import overload
@overload
def parse_id(value: str) -> int: ...
@overload
def parse_id(value: int) -> int: ...
@overload
def parse_id(value: None) -> None: ...
def parse_id(value: str | int | None) -> int | None:
if value is None:
return None
return int(value)
result1 = parse_id("42") # mypy knows: int
result2 = parse_id(42) # mypy knows: int
result3 = parse_id(None) # mypy knows: Nonemypy Configuration
# mypy.ini or pyproject.toml [tool.mypy]
[mypy]
python_version = 3.12
strict = true # Enables all strict checks
# Per-module overrides for third-party stubs
[mypy-sqlalchemy.*]
ignore_missing_imports = true
[mypy-factory.*]
ignore_missing_imports = trueStrict mode enables:
disallow_untyped_defs: All functions must have annotationsdisallow_any_generics: No barelist,dictwithout parameterswarn_return_any: Warn when returningAnyfrom typed functionswarn_unused_ignores: Catch unnecessary# type: ignorecomments
Gradual adoption with --ignore-missing-imports:
# Start by checking just specific directories
mypy myapp/services/ myapp/repositories/ --ignore-missing-imports
# Run in CI
mypy . --strict 2>&1 | tee mypy-report.txtCommon Patterns and Pitfalls
cast() for unavoidable type narrowing:
from typing import cast
def get_user_id(data: dict) -> int:
# mypy can't prove data["id"] is int without runtime check
return cast(int, data["id"])TYPE_CHECKING for circular imports:
from __future__ import annotations # Makes all annotations strings lazily evaluated
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.models import Article # Only imported when type-checking, not at runtime
def process(article: Article) -> None: # Works because of __future__ annotations
...reveal_type() for debugging:
values = [1, 2, 3]
reveal_type(values) # mypy prints: Revealed type is "builtins.list[builtins.int]"Remove reveal_type() calls before committing — they're development-only.
Types catch bugs that tests miss because they operate on code paths, not just execution paths. The upfront investment in typing pays back in IDE autocomplete, refactoring safety, and bugs caught before code ever runs.