Python - Type Hints and Static Analysis
Type annotations for runtime validation and static analysis with mypy and pyright.
Python - Type Hints and Static Analysis
Type annotations for runtime validation and static analysis with mypy and pyright.
Overview
Python's type hint system provides optional static typing to improve code quality, catch bugs early, and enhance IDE support. Type hints use Python's annotation syntax to specify expected types for variables, function parameters, and return values. Static type checkers like mypy and pyright analyse code without running it, whilst the typing module provides advanced types for complex scenarios. Type hints don't affect runtime behaviour but serve as documentation and enable powerful tooling.
flowchart TB
subgraph "Type Hint Ecosystem"
A[Source Code with Type Hints] --> B[Static Type Checker]
A --> C[Runtime Type Checking]
A --> D[IDE Tools]
B --> E[mypy]
B --> F[pyright]
B --> G[pytype]
C --> H[Pydantic]
C --> I[typeguard]
C --> J[beartype]
D --> K[Autocomplete]
D --> L[Refactoring]
D --> M[Error Detection]
end
subgraph "Type System"
N[typing Module] --> O[Primitives]
N --> P[Generics]
N --> Q[Protocols]
N --> R[Type Variables]
N --> S[Special Forms]
end
Typing Primitives and Generics
Key Concepts
- Basic types:
int,str,float,bool,bytesrepresent primitive Python types - Collection types: builtin generics
list[T],dict[K, V],set[T],tuple[...](thetyping.List/Dict/etc. aliases are deprecated since 3.9) - Unions:
X | YandX | None(PEP 604);Union[X, Y]andOptional[X]remain valid but are legacy spellings - Type aliases: Create reusable type definitions
- Generic types: Parameterise types with
TypeVarandGeneric(or PEP 695 syntax on 3.12+)
Basic Type Annotations
# Variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.8
is_active: bool = True
data: bytes = b"hello"
# Function annotations
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
def process_data(items: list) -> None:
"""Process items without returning anything."""
for item in items:
print(item)
# Multiple return values (tuple)
def get_user_info(user_id: int) -> tuple[str, int, str]:
"""Return name, age, email."""
return "Alice", 30, "alice@example.com"
# Builtin generics are standard since Python 3.9
def calculate(values: list[int]) -> int:
return sum(values)
# Legacy syntax — typing.List/Dict/Set/Tuple etc. are deprecated since 3.9;
# you will still see them in older codebases, but don't write new code with them
from typing import List
def calculate_legacy(values: List[int]) -> int:
return sum(values)
Collection Types
# Lists
def process_names(names: list[str]) -> list[str]:
return [name.upper() for name in names]
# Dictionaries
def count_words(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
# Sets
def unique_items(items: list[str]) -> set[str]:
return set(items)
# Tuples with fixed length
def get_coordinates() -> tuple[float, float]:
return 51.5074, -0.1278
# Tuples with variable length
def get_scores() -> tuple[int, ...]:
return (95, 87, 92, 88)
# Nested collections
def get_matrix() -> list[list[int]]:
return [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
# Complex nested structures
def get_user_data() -> dict[str, list[tuple[str, int]]]:
return {
"purchases": [("item1", 100), ("item2", 200)],
"views": [("page1", 50), ("page2", 75)]
}
# Optional values: PEP 604 union with None
def find_user(user_id: int) -> str | None:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # Returns str or None
# Unions (PEP 604, Python 3.10+)
def parse_value(value: int | str) -> str:
return str(value)
def get_result() -> str | None:
return None
# Legacy spellings — Optional[str] and Union[int, str] remain valid,
# but prefer str | None and int | str in new code
Type Aliases
# Simple aliases (plain assignment works on every supported version)
UserId = int
UserName = str
EmailAddress = str
def create_user(user_id: UserId, name: UserName) -> None:
pass
# Complex aliases
Vector = list[float]
Matrix = list[Vector]
Coordinates = tuple[float, float]
def calculate_distance(point1: Coordinates, point2: Coordinates) -> float:
x1, y1 = point1
x2, y2 = point2
return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
# JSON-like structures (forward references via strings)
JsonPrimitive = str | int | float | bool | None
JsonValue = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"]
JsonObject = dict[str, JsonValue]
def parse_json(data: str) -> JsonObject:
import json
return json.loads(data)
# Modern alias declaration: the `type` statement (PEP 695, Python 3.12+)
# type Point = tuple[float, float]
# type Route = list[Point]
#
# On 3.11, use plain assignment (above) or the explicit TypeAlias annotation:
from typing import TypeAlias
Point: TypeAlias = tuple[float, float]
Route: TypeAlias = list[Point]
def calculate_route_length(route: Route) -> float:
total = 0.0
for i in range(len(route) - 1):
total += calculate_distance(route[i], route[i + 1])
return total
# Function type aliases
from collections.abc import Callable
Validator = Callable[[str], bool]
Transformer = Callable[[str], str]
Handler = Callable[[int, str], None]
def apply_validator(value: str, validator: Validator) -> bool:
return validator(value)
def is_email(value: str) -> bool:
return "@" in value
result = apply_validator("test@example.com", is_email)
Generic Types
from typing import TypeVar, Generic
# Type variables
T = TypeVar("T") # Can be any type
N = TypeVar("N", int, float) # Constrained to int or float
S = TypeVar("S", bound=str) # Must be str or subclass
# Generic function
def first_item(items: list[T]) -> T | None:
"""Return first item or None."""
return items[0] if items else None
# Usage preserves types
numbers: list[int] = [1, 2, 3]
result1: int | None = first_item(numbers) # Type is int | None
names: list[str] = ["Alice", "Bob"]
result2: str | None = first_item(names) # Type is str | None
# PEP 695 generic function syntax (Python 3.12+) — no explicit TypeVar needed:
# def first_item[T](items: list[T]) -> T | None:
# return items[0] if items else None
# Generic class
class Box(Generic[T]):
"""A container that holds a value of type T."""
def __init__(self, value: T) -> None:
self._value: T = value
def get(self) -> T:
return self._value
def set(self, value: T) -> None:
self._value = value
# PEP 695 generic class syntax (Python 3.12+):
# class Box[T]:
# def __init__(self, value: T) -> None:
# self._value: T = value
# ...
# Usage
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")
# Multiple type parameters
KT = TypeVar("KT") # Key type
VT = TypeVar("VT") # Value type
class Cache(Generic[KT, VT]):
"""Generic cache with typed keys and values."""
def __init__(self) -> None:
self._data: dict[KT, VT] = {}
def get(self, key: KT) -> VT | None:
return self._data.get(key)
def set(self, key: KT, value: VT) -> None:
self._data[key] = value
def items(self) -> list[tuple[KT, VT]]:
return list(self._data.items())
# Usage
user_cache: Cache[int, str] = Cache()
user_cache.set(1, "Alice")
user_cache.set(2, "Bob")
# Generic with bounds
class Stack(Generic[T]):
"""Generic stack implementation."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
def is_empty(self) -> bool:
return len(self._items) == 0
def size(self) -> int:
return len(self._items)
# Constrained type variable
def add_numbers(a: N, b: N) -> N:
"""Add two numbers of the same numeric type."""
return a + b # type: ignore
result_int = add_numbers(1, 2) # Returns int
result_float = add_numbers(1.5, 2.5) # Returns float
# add_numbers("a", "b") # Type error: str not allowed
# Generic protocols
from typing import Protocol
class Comparable(Protocol):
"""Protocol for objects that can be compared."""
def __lt__(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool: ...
CT = TypeVar("CT", bound=Comparable)
def find_max(items: list[CT]) -> CT | None:
"""Find maximum item using comparison."""
if not items:
return None
max_item = items[0]
for item in items[1:]:
if item > max_item:
max_item = item
return max_item
Advanced Generic Patterns
from collections.abc import Callable
from typing import TypeVar, Generic, ParamSpec, Concatenate
# ParamSpec for callable signatures (Python 3.10+)
P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")
def log_call(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator that logs function calls."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@log_call
def add(a: int, b: int) -> int:
return a + b
# Concatenate for adding parameters
def with_logging(
func: Callable[Concatenate[str, P], R]
) -> Callable[P, R]:
"""Adds a logging prefix parameter."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func("LOG", *args, **kwargs)
return wrapper
# Generic dataclass
from dataclasses import dataclass
@dataclass
class Result(Generic[T]):
"""Generic result container."""
success: bool
data: T | None
error: str | None = None
@classmethod
def ok(cls, data: T) -> "Result[T]":
return cls(success=True, data=data)
@classmethod
def err(cls, error: str) -> "Result[T]":
return cls(success=False, data=None, error=error)
# Usage
def divide(a: int, b: int) -> Result[float]:
if b == 0:
return Result.err("Division by zero")
return Result.ok(a / b)
result = divide(10, 2)
if result.success:
print(f"Result: {result.data}") # Type checker knows data is float
else:
print(f"Error: {result.error}")
# Generic with multiple constraints
TNum = TypeVar("TNum", int, float, complex)
class Calculator(Generic[TNum]):
"""Generic calculator for numeric types."""
def __init__(self, initial: TNum) -> None:
self.value: TNum = initial
def add(self, other: TNum) -> "Calculator[TNum]":
self.value = self.value + other # type: ignore
return self
def get_result(self) -> TNum:
return self.value
Protocols and Structural Subtyping
Key Concepts
- Protocols: Define structural types (duck typing with type checking)
- Structural subtyping: Types match based on structure, not inheritance
- Runtime checkable: Use
@runtime_checkablefor isinstance checks - Protocol composition: Combine multiple protocols
- Variance: Covariance, contravariance, and invariance in protocols
flowchart LR
subgraph "Structural Subtyping"
A[Protocol Definition] --> B{Has Required Methods?}
B -->|Yes| C[Type Compatible]
B -->|No| D[Type Error]
end
subgraph "Nominal vs Structural"
E[Nominal Typing] --> F[Explicit Inheritance]
G[Structural Typing] --> H[Shape Matching]
F -.-> I[isinstance checks]
H -.-> J[Static checking only]
end
Basic Protocols
from typing import Protocol, runtime_checkable
# Simple protocol
class Drawable(Protocol):
"""Protocol for objects that can be drawn."""
def draw(self) -> None:
"""Draw the object."""
...
# No inheritance needed - structural typing
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render(shape: Drawable) -> None:
"""Render any drawable object."""
shape.draw()
# Both work with render() due to structural typing
render(Circle())
render(Square())
# Protocol with properties
class Sized(Protocol):
"""Protocol for objects with a size."""
@property
def size(self) -> int:
...
class Container:
def __init__(self, items: list) -> None:
self._items = items
@property
def size(self) -> int:
return len(self._items)
def print_size(obj: Sized) -> None:
print(f"Size: {obj.size}")
print_size(Container([1, 2, 3]))
# Protocol with multiple methods
class Closeable(Protocol):
"""Protocol for closeable resources."""
def open(self) -> None: ...
def close(self) -> None: ...
def is_open(self) -> bool: ...
class File:
def __init__(self, path: str) -> None:
self.path = path
self._open = False
def open(self) -> None:
self._open = True
print(f"Opening {self.path}")
def close(self) -> None:
self._open = False
print(f"Closing {self.path}")
def is_open(self) -> bool:
return self._open
def manage_resource(resource: Closeable) -> None:
"""Manage any closeable resource."""
resource.open()
try:
# Do work
pass
finally:
if resource.is_open():
resource.close()
Runtime Checkable Protocols
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
"""Protocol for comparable objects."""
def __lt__(self, other: "Comparable") -> bool: ...
def __le__(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool: ...
def __ge__(self, other: "Comparable") -> bool: ...
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
# Parameter must be at least as wide as the protocol's (contravariance);
# Any satisfies that whilst keeping the implementation simple
def __lt__(self, other: Any) -> bool:
return self.price < other.price
def __le__(self, other: Any) -> bool:
return self.price <= other.price
def __gt__(self, other: Any) -> bool:
return self.price > other.price
def __ge__(self, other: Any) -> bool:
return self.price >= other.price
# Runtime check
product = Product("Widget", 9.99)
print(isinstance(product, Comparable)) # True
# Use in sorting
def sort_items(items: list[Comparable]) -> list[Comparable]:
"""Sort comparable items."""
if not items:
return items
return sorted(items)
products: list[Comparable] = [
Product("A", 10.0),
Product("B", 5.0),
Product("C", 7.5)
]
sorted_products = sort_items(products)
# Runtime validation
@runtime_checkable
class Serializable(Protocol):
"""Protocol for serializable objects."""
def to_dict(self) -> dict: ...
def from_dict(self, data: dict) -> None: ...
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
def to_dict(self) -> dict:
return {"name": self.name, "email": self.email}
def from_dict(self, data: dict) -> None:
self.name = data["name"]
self.email = data["email"]
def serialize_if_possible(obj: object) -> dict | None:
"""Serialize object if it implements the protocol."""
if isinstance(obj, Serializable):
return obj.to_dict()
return None
user = User("Alice", "alice@example.com")
data = serialize_if_possible(user) # Works
print(data)
Advanced Protocols
from collections.abc import Iterator
from typing import Protocol, TypeVar, Generic
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
# Generic protocol
class Container(Protocol[T_co]):
"""Protocol for containers that provide items of type T."""
def __contains__(self, item: object) -> bool: ...
def __iter__(self) -> Iterator[T_co]: ...
def __len__(self) -> int: ...
class MyList(Generic[T]):
def __init__(self, items: list[T]) -> None:
self._items = items
def __contains__(self, item: object) -> bool:
return item in self._items
def __iter__(self) -> Iterator[T]:
return iter(self._items)
def __len__(self) -> int:
return len(self._items)
def count_items(container: Container[T]) -> int:
"""Count items in any container."""
return len(container)
# Callable protocol
class Validator(Protocol):
"""Protocol for validation functions."""
def __call__(self, value: str) -> bool: ...
def is_email(value: str) -> bool:
return "@" in value
def is_uk_postcode(value: str) -> bool:
import re
pattern = r"^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$"
return bool(re.match(pattern, value.upper()))
def validate_field(value: str, validator: Validator) -> bool:
"""Validate a field using any validator."""
return validator(value)
# Both functions work as validators
result1 = validate_field("test@example.com", is_email)
result2 = validate_field("SW1A 1AA", is_uk_postcode)
# Protocol inheritance
class Readable(Protocol):
"""Protocol for readable objects."""
def read(self) -> str: ...
class Writable(Protocol):
"""Protocol for writable objects."""
def write(self, data: str) -> None: ...
class ReadWritable(Readable, Writable, Protocol):
"""Protocol combining reading and writing."""
pass
class FileHandler:
def __init__(self, content: str = "") -> None:
self._content = content
def read(self) -> str:
return self._content
def write(self, data: str) -> None:
self._content = data
def copy_data(source: Readable, dest: Writable) -> None:
"""Copy data from source to destination."""
data = source.read()
dest.write(data)
# Protocol with class methods
class Instantiable(Protocol):
"""Protocol for classes that can be instantiated."""
@classmethod
def create(cls, name: str) -> "Instantiable": ...
class Product:
def __init__(self, name: str) -> None:
self.name = name
@classmethod
def create(cls, name: str) -> "Product":
return cls(name)
def create_instance(cls: type[Instantiable], name: str) -> Instantiable:
"""Create an instance of any instantiable class."""
return cls.create(name)
Variance in Protocols
from typing import Protocol, TypeVar, Generic
# Covariant (can return subtype)
T_co = TypeVar("T_co", covariant=True)
class Producer(Protocol[T_co]):
"""Protocol that produces values (covariant)."""
def produce(self) -> T_co: ...
# Contravariant (can accept supertype)
T_contra = TypeVar("T_contra", contravariant=True)
class Consumer(Protocol[T_contra]):
"""Protocol that consumes values (contravariant)."""
def consume(self, item: T_contra) -> None: ...
# Invariant (must be exact type)
T = TypeVar("T")
class Box(Protocol[T]):
"""Protocol for mutable container (invariant)."""
def get(self) -> T: ...
def set(self, value: T) -> None: ...
# Variance example
class Animal:
def speak(self) -> str:
return "Some sound"
class Dog(Animal):
def speak(self) -> str:
return "Woof"
class DogProducer:
def produce(self) -> Dog:
return Dog()
# Covariance: DogProducer is compatible with Producer[Animal]
def use_producer(producer: Producer[Animal]) -> None:
animal = producer.produce()
print(animal.speak())
use_producer(DogProducer()) # Works due to covariance
# Contravariance example
class AnimalConsumer:
def consume(self, item: Animal) -> None:
print(f"Consuming: {item.speak()}")
# AnimalConsumer is compatible with Consumer[Dog]
def use_consumer(consumer: Consumer[Dog], dog: Dog) -> None:
consumer.consume(dog)
use_consumer(AnimalConsumer(), Dog()) # Works due to contravariance
Type Checking with mypy and pyright
Key Concepts
- Static type checkers: Analyse code without running it
- mypy: the de facto standard static type checker, configurable and extensible
- pyright: Microsoft's fast type checker, powers VS Code's Python extension
- Configuration: Control checking strictness and behaviour
- Incremental checking: Check only changed files for speed
- Type stubs:
.pyifiles for libraries without type hints
flowchart TB
subgraph "Type Checking Workflow"
A[Source Code] --> B[Type Checker]
B --> C{Type Errors?}
C -->|Yes| D[Report Errors]
C -->|No| E[Success]
F[Configuration] --> B
G[Type Stubs] --> B
H[Plugins] --> B
end
subgraph "Strictness Levels"
I[Lenient] --> J[Some Checking]
K[Normal] --> L[Standard Checking]
M[Strict] --> N[Maximum Checking]
end
mypy Setup and Configuration
# Installation
pip install mypy
# Basic usage
mypy script.py
mypy src/
# Check specific Python version
mypy --python-version 3.11 script.py
# Show error codes
mypy --show-error-codes script.py
# Strict mode
mypy --strict script.py
# Incremental mode (faster)
mypy --incremental src/
# Configuration file
cat > mypy.ini << EOF
[mypy]
# Global options
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_generics = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_unreachable = True
strict_equality = True
# Per-module options
[mypy-tests.*]
disallow_untyped_defs = False
[mypy-third_party.*]
ignore_missing_imports = True
EOF
pyproject.toml Configuration
# pyproject.toml for mypy
"""
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
show_error_codes = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = [
"pandas.*",
"numpy.*",
]
ignore_missing_imports = true
"""
# Example Python file with type hints
def process_users(
users: list[dict[str, str]],
min_age: int = 18
) -> list[str]:
"""
Process users and return names.
Args:
users: List of user dictionaries
min_age: Minimum age filter
Returns:
List of user names
"""
result: list[str] = []
for user in users:
age_str: str | None = user.get("age")
if age_str is not None:
age: int = int(age_str)
if age >= min_age:
name: str = user["name"]
result.append(name)
return result
# mypy will check this thoroughly
users_data = [
{"name": "Alice", "age": "30"},
{"name": "Bob", "age": "15"},
]
names = process_users(users_data)
pyright Setup and Configuration
# Installation
npm install -g pyright
# Or use via pip
pip install pyright
# Basic usage
pyright script.py
pyright src/
# Watch mode
pyright --watch
# Generate type stubs for a library
pyright --createstub library_name
# pyrightconfig.json
"""
{
"include": ["src"],
"exclude": [
"**/node_modules",
"**/__pycache__",
"tests"
],
"ignore": ["temp/**"],
"strict": ["src/core/**"],
"typeCheckingMode": "basic",
"reportMissingImports": true,
"reportMissingTypeStubs": false,
"pythonVersion": "3.11",
"pythonPlatform": "Linux",
"executionEnvironments": [
{
"root": "src",
"pythonVersion": "3.11",
"extraPaths": ["lib"]
}
]
}
"""
# Type checking modes:
# - "off": No type checking
# - "basic": Basic type checking
# - "standard": Standard type checking (default)
# - "strict": Strictest type checking
Type Checking Examples
from typing import cast, Any
# Example 1: Missing return type (error in strict mode)
def calculate_total(items): # Missing return annotation
return sum(items)
# Fixed:
def calculate_total_fixed(items: list[float]) -> float:
return sum(items)
# Example 2: Incompatible types
def greet(name: str) -> str:
return f"Hello, {name}"
# greet(123) # Error: Argument 1 has incompatible type "int"
# Example 3: Handling None in unions
def find_user(user_id: int) -> str | None:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)
user = find_user(1)
# print(user.upper()) # Error: Item "None" has no attribute "upper"
# Fixed with type narrowing:
if user is not None:
print(user.upper()) # OK
# Example 4: Type narrowing with isinstance
def process_value(value: int | str) -> str:
if isinstance(value, int):
# Type checker knows value is int here
return f"Number: {value * 2}"
else:
# Type checker knows value is str here
return f"Text: {value.upper()}"
# Example 5: Any type (disallowed in strict mode)
def process_data(data: Any) -> Any: # Too permissive
return data.some_method() # No checking
# Better:
from typing import Protocol
class Processable(Protocol):
def process(self) -> str: ...
def process_better(data: Processable) -> str:
return data.process()
# Example 6: Type guards
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
"""Check if all items are strings."""
return all(isinstance(item, str) for item in val)
def process_strings(items: list[object]) -> None:
if is_string_list(items):
# Type checker knows items is list[str] here
for item in items:
print(item.upper()) # OK
# Example 7: Literal types
from typing import Literal
Mode = Literal["read", "write", "append"]
def open_file(path: str, mode: Mode) -> None:
print(f"Opening {path} in {mode} mode")
open_file("data.txt", "read") # OK
# open_file("data.txt", "invalid") # Error: invalid literal
# Example 8: TypedDict
from typing import TypedDict
class UserDict(TypedDict):
name: str
age: int
email: str
def create_user(user: UserDict) -> None:
print(f"Creating user: {user['name']}")
user_data: UserDict = {
"name": "Alice",
"age": 30,
"email": "alice@example.com"
}
create_user(user_data) # OK
# Missing key error:
# incomplete_user: UserDict = {"name": "Bob"} # Error
# Example 9: Overload
from typing import overload
@overload
def get_item(container: list[str], index: int) -> str: ...
@overload
def get_item(container: dict[str, int], index: str) -> int: ...
def get_item(container, index):
return container[index]
# Type checker knows the return type
result1: str = get_item(["a", "b"], 0)
result2: int = get_item({"x": 1}, "x")
# Example 10: Revealing types (debugging)
reveal_type(calculate_total_fixed([1.0, 2.0])) # Shows: float
Handling Type Checker Errors
from typing import Any, cast, TYPE_CHECKING
# Ignore specific error
def legacy_function() -> int:
return "not an int" # type: ignore[return-value]
# Ignore entire line
result = some_untyped_library() # type: ignore
# Cast for complex scenarios
data: dict[str, Any] = {"items": [1, 2, 3]}
items: list[int] = cast(list[int], data["items"])
# Assert type narrowing
def process_optional(value: str | None) -> str:
assert value is not None # Type narrowing
return value.upper() # OK after assert
# Import only for type checking (no runtime cost)
if TYPE_CHECKING:
from expensive_module import HeavyClass
def use_heavy(obj: "HeavyClass") -> None:
# obj is only checked statically, not at runtime
pass
# Platform-specific types
import sys
if sys.platform == "win32":
def windows_only() -> None:
pass
elif sys.platform == "linux":
def linux_only() -> None:
pass
# Suppress errors for entire file
# mypy: ignore-errors
# Per-module configuration in code
# mypy: disallow-untyped-defs, warn-return-any
Gradual Typing Strategies
Key Concepts
- Gradual typing: Mix typed and untyped code in the same project
- Progressive adoption: Start with critical modules, expand over time
- Type coverage: Track percentage of code with type hints
- Stub files: Add types to third-party libraries
- Dynamic typing: When to use
Anyand when to avoid it
flowchart LR
subgraph "Gradual Typing Journey"
A[Untyped Codebase] --> B[Add Types to Core]
B --> C[Type Public APIs]
C --> D[Type Internal Functions]
D --> E[Strict Mode]
end
subgraph "Strategies"
F[Bottom-Up] --> G[Start with utilities]
H[Top-Down] --> I[Start with interfaces]
J[Critical Path] --> K[Type hot paths first]
end
Progressive Type Adoption
# Phase 1: Start with function signatures (no body checking)
def process_data(data: list) -> list:
# Untyped implementation
result = []
for item in data:
result.append(item * 2)
return result
# Phase 2: Add generic types
from typing import TypeVar
T = TypeVar("T")
def process_data_v2(data: list[T]) -> list[T]:
# Still loose typing in body
result = []
for item in data:
result.append(item * 2) # Type checker might warn
return result
# Phase 3: Full type annotations
N = TypeVar("N", int, float)
def process_data_v3(data: list[N]) -> list[N]:
result: list[N] = []
for item in data:
# Type checker knows item is N (int or float)
doubled: N = item * 2 # type: ignore[assignment]
result.append(doubled)
return result
# Phase 4: Type-safe with overloads
from typing import overload
@overload
def process_data_v4(data: list[int]) -> list[int]: ...
@overload
def process_data_v4(data: list[float]) -> list[float]: ...
def process_data_v4(data):
return [item * 2 for item in data]
# Strategy 1: Type critical paths first
class UserService:
"""Start with public API methods."""
def get_user(self, user_id: int) -> dict[str, Any] | None:
"""Public method - typed first."""
return self._fetch_from_db(user_id)
def _fetch_from_db(self, user_id):
"""Internal method - type later."""
# Implementation
pass
# Strategy 2: Use Any for gradual migration
from typing import Any
class LegacySystem:
def process(self, data: Any) -> Any:
"""Accept anything for now."""
# Legacy code
return self._transform(data)
def _transform(self, data: Any) -> Any:
# Will type this later
return data
# Strategy 3: Add types to new code only
class ModernService:
"""All new code is typed."""
def new_feature(self, input_data: list[str]) -> dict[str, int]:
"""New method with full types."""
return {item: len(item) for item in input_data}
def legacy_method(self, data):
"""Old method left untyped for now."""
# Don't touch legacy code yet
pass
Type Coverage Tracking
# Using mypy with coverage report
mypy --html-report ./mypy-report src/
# Using pyright
pyright --stats
# Custom script to track coverage
cat > check_coverage.py << 'EOF'
#!/usr/bin/env python3
"""Track type hint coverage."""
import ast
import os
from pathlib import Path
def analyze_file(filepath: Path) -> tuple[int, int]:
"""
Analyze a Python file for type hints.
Returns:
Tuple of (annotated_functions, total_functions)
"""
with open(filepath) as f:
tree = ast.parse(f.read())
total = 0
annotated = 0
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
total += 1
# Check if function has return annotation
if node.returns is not None:
# Check if all parameters have annotations
all_params_annotated = all(
arg.annotation is not None
for arg in node.args.args
if arg.arg != "self" and arg.arg != "cls"
)
if all_params_annotated:
annotated += 1
return annotated, total
def scan_directory(directory: Path) -> dict[str, tuple[int, int]]:
"""Scan directory for Python files."""
results: dict[str, tuple[int, int]] = {}
for filepath in directory.rglob("*.py"):
if "__pycache__" in str(filepath):
continue
results[str(filepath)] = analyze_file(filepath)
return results
if __name__ == "__main__":
results = scan_directory(Path("src"))
total_annotated = sum(r[0] for r in results.values())
total_functions = sum(r[1] for r in results.values())
coverage = (total_annotated / total_functions * 100) if total_functions > 0 else 0
print(f"Type Coverage: {coverage:.1f}%")
print(f"Annotated: {total_annotated}/{total_functions} functions")
# Show files needing attention
print("\nFiles with low coverage:")
for filepath, (annotated, total) in results.items():
if total > 0:
file_coverage = annotated / total * 100
if file_coverage < 50:
print(f" {filepath}: {file_coverage:.0f}%")
EOF
Stub Files for Untyped Libraries
# Create stub file for untyped library
# File: stubs/untyped_lib/__init__.pyi
"""Type stubs for untyped_lib."""
from typing import Any
class Connection:
def __init__(self, host: str, port: int) -> None: ...
def connect(self) -> bool: ...
def disconnect(self) -> None: ...
def execute(self, query: str) -> list[dict[str, Any]]: ...
def close(self) -> None: ...
def create_connection(
host: str,
port: int = 5432,
username: str | None = None,
password: str | None = None,
) -> Connection: ...
def parse_config(config_file: str) -> dict[str, Any]: ...
# Configure mypy to use stubs
# mypy.ini:
"""
[mypy]
mypy_path = stubs
"""
# Using the stubbed library with full type checking
from untyped_lib import create_connection, Connection
def get_users() -> list[dict[str, Any]]:
"""Get users from database."""
conn: Connection = create_connection("localhost", 5432)
try:
conn.connect()
result: list[dict[str, Any]] = conn.execute("SELECT * FROM users")
return result
finally:
conn.close()
Handling Dynamic Code
from typing import Any, TypeVar, cast, Protocol
# Strategy 1: TypedDict for dynamic dictionaries
from typing import TypedDict
class ConfigDict(TypedDict, total=False):
"""Configuration dictionary with known keys."""
host: str
port: int
debug: bool
options: dict[str, Any]
def load_config() -> ConfigDict:
"""Load configuration."""
return {
"host": "localhost",
"port": 8000,
"debug": True,
}
config = load_config()
print(config["host"]) # Type checker knows this is str
# Strategy 2: Runtime type checking with protocols
class Configurable(Protocol):
"""Protocol for configurable objects."""
def configure(self, options: dict[str, Any]) -> None: ...
def apply_config(obj: Configurable, config: dict[str, Any]) -> None:
"""Apply configuration to any configurable object."""
obj.configure(config)
# Strategy 3: Generic factories
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True) # mypy requires covariance here
class Factory(Protocol[T_co]):
"""Generic factory protocol."""
def create(self, **kwargs: Any) -> T_co: ...
def create_instance(factory: Factory[T], **kwargs: Any) -> T:
"""Create instance using factory."""
return factory.create(**kwargs)
# Strategy 4: Careful use of cast
def get_from_cache(key: str) -> Any:
"""Get value from untyped cache."""
# Imagine this returns Any from Redis, etc.
return {"name": "Alice", "age": 30}
def get_user_from_cache(user_id: int) -> dict[str, Any]:
"""Get user with type assertion."""
result = get_from_cache(f"user:{user_id}")
# We know the structure, so cast it
return cast(dict[str, Any], result)
# Strategy 5: Type narrowing for dynamic access
def safe_get_attr(obj: Any, attr: str, default: T) -> T:
"""Safely get attribute with type preservation."""
value = getattr(obj, attr, default)
if isinstance(value, type(default)):
return value
return default
class Settings:
timeout: int = 30
debug: bool = False
settings = Settings()
timeout: int = safe_get_attr(settings, "timeout", 60)
Migration Best Practices
# Best Practice 1: Start with test files
import pytest
def test_calculator() -> None:
"""Typed test function."""
from calculator import add, subtract
result: int = add(2, 3)
assert result == 5
result2: int = subtract(5, 3)
assert result2 == 2
# Best Practice 2: Type decorators and wrappers
from collections.abc import Callable
from functools import wraps
from typing import TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def typed_decorator(
func: Callable[P, R]
) -> Callable[P, R]:
"""Decorator that preserves type information."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@typed_decorator
def calculate(a: int, b: int) -> int:
"""Type information preserved through decorator."""
return a + b
# Best Practice 3: Module-level __all__ with types
__all__: list[str] = [
"calculate",
"process_data",
"UserService",
]
# Best Practice 4: Document migration status
"""
Module: data_processor
Type Hint Status: Partial (60% coverage)
Migration TODO:
- Add types to _internal_helper functions
- Replace Any with specific types in process_legacy
- Add Protocol for plugin system
"""
# Best Practice 5: Name complex types with an alias
# Modern form — the `type` statement (PEP 695, Python 3.12+):
# type JsonValue = (
# None | bool | int | float | str
# | list["JsonValue"] | dict[str, "JsonValue"]
# )
# On 3.11, use TypeAlias (or plain assignment):
from typing import TypeAlias
JsonValue: TypeAlias = (
None
| bool
| int
| float
| str
| list["JsonValue"]
| dict[str, "JsonValue"]
)
def process_json(data: JsonValue) -> str:
"""Process JSON data with proper typing."""
return str(data)
Common Typing Patterns and Pitfalls
Key Concepts
- Common patterns: Proven typing approaches for frequent scenarios
- Pitfalls: Mistakes to avoid when using type hints
- Best practices: Guidelines for maintainable type-annotated code
- Performance: Type hints have no runtime performance cost
- Forward references: Handle circular dependencies with string annotations
Common Patterns
from typing import Any, TypeVar, Generic, Protocol
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
# Pattern 1: Builder pattern with types
T = TypeVar("T")
class QueryBuilder(Generic[T]):
"""Type-safe query builder."""
def __init__(self, model: type[T]) -> None:
self._model = model
self._filters: list[str] = []
self._limit: int | None = None
def filter(self, condition: str) -> "QueryBuilder[T]":
"""Add filter condition."""
self._filters.append(condition)
return self
def limit(self, count: int) -> "QueryBuilder[T]":
"""Set result limit."""
self._limit = count
return self
def execute(self) -> list[T]:
"""Execute query and return results."""
# Implementation
return []
class User:
pass
# Usage with preserved types
users: list[User] = (
QueryBuilder(User)
.filter("age > 18")
.limit(10)
.execute()
)
# Pattern 2: Repository pattern
from abc import ABC, abstractmethod
class Repository(ABC, Generic[T]):
"""Generic repository interface."""
@abstractmethod
def get(self, id: int) -> T | None:
"""Get entity by ID."""
...
@abstractmethod
def save(self, entity: T) -> T:
"""Save entity."""
...
@abstractmethod
def delete(self, id: int) -> bool:
"""Delete entity."""
...
@abstractmethod
def list(self) -> list[T]:
"""List all entities."""
...
class UserRepository(Repository[User]):
"""User repository implementation."""
def get(self, id: int) -> User | None:
# Implementation
return None
def save(self, entity: User) -> User:
return entity
def delete(self, id: int) -> bool:
return True
def list(self) -> list[User]:
return []
# Pattern 3: Factory pattern with protocol
class Creatable(Protocol):
"""Protocol for classes that can be created."""
@classmethod
def create(cls, **kwargs: Any) -> "Creatable":
...
class Factory(Generic[T]):
"""Generic factory."""
def __init__(self, cls: type[T]) -> None:
self._cls = cls
def create(self, **kwargs: Any) -> T:
"""Create instance."""
return self._cls(**kwargs) # type: ignore
# Pattern 4: Callable with specific signatures
ValidationFunc = Callable[[str], bool]
TransformFunc = Callable[[str], str]
AsyncHandler = Callable[[int, str], None]
def apply_validators(
value: str,
validators: list[ValidationFunc]
) -> bool:
"""Apply multiple validators."""
return all(validator(value) for validator in validators)
# Pattern 5: Context manager with types
from typing import TypeVar, Generic
R = TypeVar("R")
@contextmanager
def managed_resource(resource_path: str) -> Iterator[str]:
"""Context manager for resources."""
resource = f"Resource: {resource_path}"
print(f"Acquiring {resource}")
try:
yield resource
finally:
print(f"Releasing {resource}")
# Usage
with managed_resource("/path/to/resource") as resource:
print(f"Using {resource}")
# Pattern 6: Immutable data with frozen dataclass
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
"""Immutable point."""
x: float
y: float
def distance_to(self, other: "Point") -> float:
"""Calculate distance to another point."""
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
# Pattern 7: Discriminated unions with Literal
from typing import Literal
@dataclass
class SuccessResult:
status: Literal["success"]
data: dict[str, Any]
@dataclass
class ErrorResult:
status: Literal["error"]
message: str
code: int
Result = SuccessResult | ErrorResult
def process_result(result: Result) -> None:
"""Process result with type narrowing."""
if result.status == "success":
# Type checker knows this is SuccessResult
print(f"Data: {result.data}")
else:
# Type checker knows this is ErrorResult
print(f"Error {result.code}: {result.message}")
# Pattern 8: Singleton with types
class Singleton:
"""Thread-safe singleton with type hints."""
_instance: "Singleton | None" = None
def __new__(cls) -> "Singleton":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@classmethod
def get_instance(cls) -> "Singleton":
"""Get singleton instance."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
Common Pitfalls
from typing import Any, TypeVar
# Pitfall 1: Mutable default arguments
def append_to_list(item: str, items: list[str] = []) -> list[str]: # WRONG!
"""DON'T: Mutable default is shared across calls."""
items.append(item)
return items
# Fix: Use None and create new list
def append_to_list_fixed(
item: str,
items: list[str] | None = None
) -> list[str]:
"""DO: Create new list if None."""
if items is None:
items = []
items.append(item)
return items
# Pitfall 2: Writing new code with the deprecated typing aliases
from typing import List, Dict # Deprecated since 3.9 — legacy code only
def process_items_legacy(items: List[str]) -> Dict[str, int]:
"""DON'T: typing.List/Dict still work but are deprecated."""
return {item: len(item) for item in items}
# Use the builtin generics instead:
def process_items(items: list[str]) -> dict[str, int]:
"""DO: Use builtin generics (standard since 3.9)."""
return {item: len(item) for item in items}
# Pitfall 3: Not handling None in unions
def get_user(user_id: int) -> str | None:
"""Returns username or None."""
return None
# WRONG: Not checking for None
username = get_user(1)
# print(username.upper()) # Error: None has no attribute 'upper'
# FIX 1: Check explicitly
username = get_user(1)
if username is not None:
print(username.upper())
# FIX 2: Provide default
username = get_user(1) or "Anonymous"
print(username.upper())
# Pitfall 4: Overly broad Any
def process_data(data: Any) -> Any: # WRONG: Too permissive
"""This provides no type safety."""
return data.some_method() # No checking whatsoever
# Fix: Use proper types
from typing import Protocol
class Processable(Protocol):
def some_method(self) -> str: ...
def process_data_fixed(data: Processable) -> str: # BETTER
"""Now type-checked."""
return data.some_method()
# Pitfall 5: Forgetting TypeVar bounds
T = TypeVar("T")
def combine(a: T, b: T) -> T: # WRONG: Can't guarantee this works
return a + b # type: ignore # Error: unsupported operand
# Fix: Use bound or constraint
Addable = TypeVar("Addable", int, float, str)
def combine_fixed(a: Addable, b: Addable) -> Addable:
return a + b # type: ignore # Now constrained to addable types
# Pitfall 6: Circular imports
# file1.py
"""
from file2 import ClassB
class ClassA:
def method(self, b: ClassB) -> None: # Circular import!
pass
"""
# Fix: Use string annotations or TYPE_CHECKING
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from file2 import ClassB
class ClassA:
def method(self, b: "ClassB") -> None: # String annotation
pass
# Or use __future__ import (Python 3.7+)
from __future__ import annotations
# Now all annotations are strings automatically
# Pitfall 7: Not using Sequence for read-only operations
def sum_items(items: list[int]) -> int: # WRONG: Too restrictive
"""This only accepts list, not tuple."""
return sum(items)
# sum_items((1, 2, 3)) # Error: Expected list, got tuple
# Fix: Use Sequence for read-only
from collections.abc import Sequence
def sum_items_fixed(items: Sequence[int]) -> int: # BETTER
"""Accepts list, tuple, and other sequences."""
return sum(items)
sum_items_fixed([1, 2, 3]) # OK
sum_items_fixed((1, 2, 3)) # OK
# Pitfall 8: Contravariance confusion
from collections.abc import Callable
def process_with_callback(
items: list[int],
callback: Callable[[int], None]
) -> None:
"""Process items with callback."""
for item in items:
callback(item)
def handle_number(value: int | float) -> None:
"""Handle any number."""
print(value)
# This works due to contravariance:
process_with_callback([1, 2, 3], handle_number)
# int | float is acceptable where int is expected
# Pitfall 9: Not specifying total=False in TypedDict
from typing import TypedDict
class UserDict(TypedDict): # All fields required by default
name: str
email: str
age: int
# user: UserDict = {"name": "Alice", "email": "a@b.com"} # Error: missing 'age'
# Fix: Use total=False for optional fields
class UserDictFixed(TypedDict, total=False):
name: str # Optional
email: str # Optional
age: int # Optional
user: UserDictFixed = {"name": "Alice"} # OK
# Or mix required and optional
class UserDictMixed(TypedDict):
name: str # Required
email: str # Required
class UserDictOptional(UserDictMixed, total=False):
age: int # Optional
phone: str # Optional
# Pitfall 10: Type narrowing doesn't propagate
def process_optional(value: int | None) -> None:
"""Type narrowing pitfall."""
if value is not None:
numbers = [value]
# Type checker knows value is int here
# But here value could be None again in some checkers
# Always check again or use a different variable
if value is not None:
print(value + 10) # Safe
# Better: Extract to variable after narrowing
def process_optional_better(value: int | None) -> None:
"""Better type narrowing."""
if value is None:
return
# Now value is definitely int for the rest of the function
numbers = [value]
result = value + 10
print(result)
Best Practices
from collections.abc import Sequence
from typing import TypeVar, Any, Protocol, Final, ClassVar
# Best Practice 1: Use Final for constants
from typing import Final
MAX_RETRIES: Final = 3
API_URL: Final[str] = "https://api.example.com"
# MAX_RETRIES = 5 # Error: Cannot assign to final
# Best Practice 2: Use ClassVar for class variables
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class User:
"""User with class-level counter."""
name: str
email: str
# Class variable
user_count: ClassVar[int] = 0
def __post_init__(self) -> None:
User.user_count += 1
# Best Practice 3: Document complex types with aliases
# Instead of this everywhere:
# def process(data: dict[str, list[tuple[int, str, float]]]) -> None:
# pass
# Use a named alias — the `type` statement on Python 3.12+:
# type DataPoint = tuple[int, str, float]
# type DataSet = dict[str, list[DataPoint]]
# On 3.11, use TypeAlias:
from typing import TypeAlias
DataPoint: TypeAlias = tuple[int, str, float]
DataSet: TypeAlias = dict[str, list[DataPoint]]
def process(data: DataSet) -> None:
"""Much clearer with named type."""
pass
# Best Practice 4: Use NewType for distinct types
from typing import NewType
UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)
def get_user(user_id: UserId) -> str:
"""Get user by ID."""
return f"User {user_id}"
def get_product(product_id: ProductId) -> str:
"""Get product by ID."""
return f"Product {product_id}"
user_id = UserId(1)
product_id = ProductId(1)
get_user(user_id) # OK
# get_user(product_id) # Error: Expected UserId, got ProductId
# get_user(1) # Error: Expected UserId, got int
# Best Practice 5: Type narrow early and consistently
def process_value(value: str | None) -> str:
"""Process value with early return."""
# Early return for None
if value is None:
return ""
# Now value is definitely str for rest of function
cleaned = value.strip()
uppercased = cleaned.upper()
return uppercased
# Best Practice 6: Use Protocol for duck typing
class Drawable(Protocol):
"""Protocol instead of ABC for duck typing."""
def draw(self) -> None: ...
def get_bounds(self) -> tuple[int, int, int, int]: ...
def render_all(shapes: list[Drawable]) -> None:
"""Render any drawable objects."""
for shape in shapes:
shape.draw()
# No inheritance needed:
class Circle:
def draw(self) -> None:
print("Drawing circle")
def get_bounds(self) -> tuple[int, int, int, int]:
return (0, 0, 100, 100)
# Best Practice 7: Use ParamSpec for decorators
from collections.abc import Callable
from typing import ParamSpec, TypeVar
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator preserving signature."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@log_calls
def add(a: int, b: int) -> int:
"""Signature preserved through decorator."""
return a + b
# Type checker knows signature:
result: int = add(1, 2)
# Best Practice 8: Type guard for runtime checks
from typing import TypeGuard
def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
"""Runtime check with type narrowing."""
return all(isinstance(x, str) for x in val)
def process_strings(items: list[Any]) -> int:
"""Process list if all strings."""
if is_string_list(items):
# Type checker knows items is list[str] here
return sum(len(s) for s in items)
return 0
# Best Practice 9: Use Literal for string constants
from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
def log(message: str, level: LogLevel = "INFO") -> None:
"""Log with type-safe levels."""
print(f"[{level}] {message}")
log("Starting", "INFO") # OK
# log("Error", "CRITICAL") # Error: Invalid literal
# Best Practice 10: Document type assumptions
def calculate_average(numbers: Sequence[float]) -> float:
"""
Calculate average of numbers.
Args:
numbers: Non-empty sequence of numbers
Returns:
The arithmetic mean
Raises:
ValueError: If numbers is empty
Note:
Type system can't enforce non-empty,
so we check at runtime.
"""
if not numbers:
raise ValueError("Cannot calculate average of empty sequence")
return sum(numbers) / len(numbers)
Quick Reference
| Category | Pattern | Example |
|---|---|---|
| Basic Types | Variable annotation | name: str = "Alice" |
| Function annotation | def greet(name: str) -> str: |
|
| Optional type | def find(id: int) -> str | None: |
|
| Union | value: int | str (legacy: Union[int, str]) |
|
| Collections | List | items: list[str] (legacy: List[str]) |
| Dictionary | data: dict[str, int] |
|
| Tuple (fixed) | coords: tuple[float, float] |
|
| Tuple (variable) | scores: tuple[int, ...] |
|
| Generics | Type variable | T = TypeVar("T") |
| Generic class | class Box(Generic[T]): or class Box[T]: (3.12+) |
|
| Generic function | def first(items: list[T]) -> T | None: or def first[T](...) (3.12+) |
|
| Protocols | Define protocol | class Drawable(Protocol): |
| Runtime checkable | @runtime_checkable |
|
| Protocol with generics | class Container(Protocol[T]): |
|
| Type Checkers | Run mypy | mypy script.py |
| Strict mode | mypy --strict src/ |
|
| Run pyright | pyright script.py |
|
| Ignore error | result = func() # type: ignore |
|
| Advanced | Type alias | type Vector = list[float] (3.12+); Vector: TypeAlias = list[float] (3.11) |
| Callable | Handler = Callable[[int], str] (collections.abc.Callable) |
|
| Literal | Mode = Literal["r", "w", "a"] |
|
| TypedDict | class User(TypedDict): |
|
| Protocol | class Closeable(Protocol): |
|
| NewType | UserId = NewType("UserId", int) |
|
| Final | MAX_SIZE: Final = 100 |
|
| TypeGuard | def is_str_list(...) -> TypeGuard[list[str]]: |
Common Issues and Solutions
| Issue | Solution |
|---|---|
error: Incompatible return value type |
Check function return type matches annotation |
error: Missing return statement |
Add return statement or use -> None |
error: "list" requires type parameters |
Use list[int] not bare list (under disallow_any_generics) |
error: Name "List" is not defined |
Legacy code importing typing aliases — prefer builtin list[...] instead |
error: Optional type needs argument |
Use str | None (or legacy Optional[str]), not bare Optional |
error: Argument has incompatible type |
Check parameter types match call arguments |
X | Y syntax fails at runtime |
PEP 604 needs Python 3.10+ (or from __future__ import annotations) |
Type checker not finding issues |
Enable stricter checking in configuration |
Circular import with types |
Use if TYPE_CHECKING: or string annotations |
Mutable default argument |
Use None as default, create mutable in function |
None has no attribute X |
Check for None before accessing attributes |
Callable signature not preserved |
Use ParamSpec in decorators (Python 3.10+) |
Protocol not matching |
Ensure all protocol methods are implemented |
TypeVar bound not working |
Use bound= or specify allowed types explicitly |
type X = ... is a syntax error |
The type statement is Python 3.12+; on 3.11 use TypeAlias or plain assignment |
Type narrowing not working |
Use isinstance checks or type guards |
Any making everything pass |
Avoid Any; use Protocol or proper types |
Type stubs not found |
Install types package: pip install types-requests |
reveal_type not found |
typing.reveal_type exists since 3.11; older code should remove it after debugging |
Related Topics
The following topics complement Python type hints and static analysis work, and would make excellent additions to your cheatsheet collection:
- Pydantic - Runtime type validation using type hints for data validation, serialisation, and settings management
- Python Patterns - Design patterns enhanced by type hints including Factory, Strategy, and Repository patterns
- Python Debugging - Debugging tools that leverage type information for better inspection and error detection
- FastAPI - Modern web framework built on type hints for automatic validation and documentation generation
- Python pytest - Testing framework with type-checked test fixtures and parametrisation
- Python AsyncIO - Asynchronous programming with proper type hints for coroutines and async iterators