Agent skill
python-type-hints-guide
Comprehensive reference guide for Python type hints, static type checking with mypy, modern type annotation patterns (PEP 484, 585, 604, 612, 613), and type hint best practices for Python 3.9+. Use during code reviews to ensure proper type annotation usage, evaluate mypy configuration, and identify type hint anti-patterns.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/python-type-hints-guide-clostaunau-holiday-card
SKILL.md
Python Type Hints Guide
Purpose
This skill provides comprehensive guidance on Python type hints and static type checking for Python 3.9+ codebases. It serves as a reference during code reviews to ensure proper type annotation usage, evaluate type hint quality, and promote best practices for gradual typing adoption.
This skill should be referenced by the uncle-duke-python agent when:
- Reviewing Python code for type hint usage
- Evaluating type annotation quality and consistency
- Identifying type hint anti-patterns
- Recommending mypy configuration improvements
- Guiding gradual typing adoption strategies
Context
Type hints (introduced in PEP 484) enable static type checking in Python while maintaining the language's dynamic nature. Modern Python (3.9+) has significantly improved type hint syntax with built-in generic types and the union operator. Proper type hints improve:
- Code documentation: Self-documenting function signatures
- IDE support: Better autocomplete and refactoring
- Bug detection: Catch type errors before runtime
- Maintainability: Easier to understand code contracts
- Refactoring safety: Catch breaking changes early
This guide focuses on modern Python (3.9+) patterns and assumes familiarity with basic Python syntax.
Prerequisites
- Python 3.9 or later (for built-in generic types)
- Python 3.10+ recommended (for union operator |)
- mypy installed for static type checking:
pip install mypy - Understanding of Python's dynamic typing system
Type Hints Basics (PEP 484)
Basic Types
Use built-in type names directly for simple types:
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(x: int, y: int) -> int:
return x + y
def calculate_average(scores: list[float]) -> float:
return sum(scores) / len(scores)
def is_valid(flag: bool) -> bool:
return not flag
None Type
Use None for functions that don't return a value:
def log_message(message: str) -> None:
print(f"[LOG] {message}")
# No return statement or explicit return None
Optional Types
Use Optional[T] or T | None (Python 3.10+) for values that can be None:
from typing import Optional
# Python 3.9 style
def find_user(user_id: int) -> Optional[dict]:
# May return dict or None
return None
# Python 3.10+ style (preferred)
def find_user_modern(user_id: int) -> dict | None:
# May return dict or None
return None
# With default None parameter
def greet(name: str, title: str | None = None) -> str:
if title:
return f"Hello, {title} {name}!"
return f"Hello, {name}!"
Collection Types (Python 3.9+)
Python 3.9+ allows using built-in collection types directly (lowercase):
# Modern Python 3.9+ (preferred)
def process_names(names: list[str]) -> dict[str, int]:
return {name: len(name) for name in names}
def unique_items(items: set[int]) -> list[int]:
return sorted(items)
def get_coordinates() -> tuple[float, float]:
return (42.0, -71.0)
# Variable-length tuple (homogeneous)
def process_values(values: tuple[int, ...]) -> int:
return sum(values)
# Nested collections
def process_matrix(matrix: list[list[float]]) -> float:
return sum(sum(row) for row in matrix)
Legacy Python 3.8 and below (avoid in modern code):
from typing import List, Dict, Set, Tuple
def process_names(names: List[str]) -> Dict[str, int]:
return {name: len(name) for name in names}
Union Types
Use Union[T, U] or T | U (Python 3.10+) for multiple possible types:
from typing import Union
# Python 3.9 style
def process_id(user_id: Union[int, str]) -> str:
return str(user_id)
# Python 3.10+ style (preferred)
def process_id_modern(user_id: int | str) -> str:
return str(user_id)
# Multiple types
def parse_value(value: int | float | str | None) -> float:
if value is None:
return 0.0
return float(value)
Any Type
Use Any when type cannot be determined or for gradual typing:
from typing import Any
# Accept any type (escape hatch)
def legacy_function(data: Any) -> Any:
# Used when migrating untyped code
return data
# Dict with any values
def process_config(config: dict[str, Any]) -> None:
# Config can have values of any type
pass
Warning: Overusing Any defeats the purpose of type hints. Use sparingly and document why.
Advanced Types
Generic Types (TypeVar, Generic)
Create reusable generic functions and classes:
from typing import TypeVar, Generic
# Type variable for generic functions
T = TypeVar('T')
def first_item(items: list[T]) -> T | None:
return items[0] if items else None
# Usage preserves type information
names: list[str] = ["Alice", "Bob"]
first_name: str | None = first_item(names) # Type checker knows this is str | None
numbers: list[int] = [1, 2, 3]
first_number: int | None = first_item(numbers) # Type checker knows this is int | None
# Generic class
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
return self._items.pop() if self._items else None
# Usage
string_stack: Stack[str] = Stack()
string_stack.push("hello") # OK
string_stack.push(42) # Type error!
# Bounded type variable (constrains to subclasses)
from numbers import Number
NumT = TypeVar('NumT', bound=Number)
def add_numbers(x: NumT, y: NumT) -> NumT:
return x + y # type: ignore[return-value]
Protocol Classes (Structural Subtyping - PEP 544)
Define interfaces based on structure (duck typing with type checking):
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
# Both Circle and Square satisfy Drawable protocol
# without explicit inheritance
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # OK
render(Square()) # OK
# Protocol with properties
class Sized(Protocol):
@property
def size(self) -> int:
...
# Real-world example: file-like objects
class SupportsRead(Protocol):
def read(self, size: int = -1) -> str:
...
def process_file(file: SupportsRead) -> str:
return file.read()
Literal Types (PEP 586)
Specify exact literal values allowed:
from typing import Literal
def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]) -> None:
print(f"Log level set to {level}")
set_log_level("DEBUG") # OK
set_log_level("TRACE") # Type error!
# Multiple literals
def open_file(path: str, mode: Literal["r", "w", "a", "rb", "wb"]) -> None:
pass
# Literal booleans (rarely needed)
def process(flag: Literal[True]) -> None:
# Only accepts True, not False
pass
# Combining with Union
Mode = Literal["read", "write", "append"]
def process_mode(mode: Mode | None = None) -> None:
pass
TypedDict (PEP 589)
Define dictionaries with specific key-value type requirements:
from typing import TypedDict, NotRequired
# Basic TypedDict
class User(TypedDict):
name: str
age: int
email: str
def create_user(user: User) -> None:
print(f"Creating user: {user['name']}")
# Usage
user: User = {"name": "Alice", "age": 30, "email": "alice@example.com"}
create_user(user) # OK
# Missing required key
bad_user: User = {"name": "Bob", "age": 25} # Type error! Missing 'email'
# Optional keys (Python 3.11+)
class UserOptional(TypedDict):
name: str
age: int
email: NotRequired[str] # Optional key
# For Python 3.9-3.10, use total=False
class PartialUser(TypedDict, total=False):
email: str
phone: str
class RequiredUser(PartialUser):
name: str # Required
age: int # Required
# Inheritance
class Employee(User):
employee_id: int
department: str
Final Types (PEP 591)
Indicate values that should not be reassigned or overridden:
from typing import Final, final
# Final variable (constant)
MAX_CONNECTIONS: Final[int] = 100
# Type error if reassigned
MAX_CONNECTIONS = 200 # Type error!
# Final class attribute
class Config:
API_URL: Final[str] = "https://api.example.com"
# Final method (cannot be overridden)
class Base:
@final
def process(self) -> None:
print("Processing")
class Derived(Base):
def process(self) -> None: # Type error! Cannot override @final method
print("Custom processing")
# Final class (cannot be subclassed)
@final
class ImmutablePoint:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
class Point3D(ImmutablePoint): # Type error! Cannot subclass @final class
pass
NewType
Create distinct types for type safety:
from typing import NewType
# Create new types for domain concepts
UserId = NewType('UserId', int)
OrderId = NewType('OrderId', int)
def get_user(user_id: UserId) -> dict:
return {"id": user_id, "name": "User"}
def get_order(order_id: OrderId) -> dict:
return {"id": order_id, "status": "pending"}
# Usage
user_id = UserId(42)
order_id = OrderId(100)
get_user(user_id) # OK
get_user(order_id) # Type error! OrderId is not UserId
get_user(42) # Type error! int is not UserId
# NewType is zero-cost at runtime (just returns the value)
# But provides type safety during static checking
Callable Types
Type hint for callable objects (functions, lambdas, callables):
from typing import Callable
# Basic callable: (param_types...) -> return_type
def apply_operation(x: int, operation: Callable[[int], int]) -> int:
return operation(x)
# Usage
def double(n: int) -> int:
return n * 2
result = apply_operation(5, double) # OK
result = apply_operation(5, lambda x: x * 3) # OK
# Multiple parameters
def apply_binary(
x: int,
y: int,
operation: Callable[[int, int], int]
) -> int:
return operation(x, y)
apply_binary(5, 3, lambda a, b: a + b) # OK
# No parameters
def run_callback(callback: Callable[[], None]) -> None:
callback()
# Variable arguments (use ... for flexibility)
def log_with_formatter(
message: str,
formatter: Callable[..., str]
) -> None:
formatted = formatter(message)
print(formatted)
# Callback type alias
Validator = Callable[[str], bool]
def validate_input(value: str, validator: Validator) -> bool:
return validator(value)
Type Aliases
Create readable aliases for complex types:
from typing import TypeAlias
# Simple alias
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]
def scale_vector(vector: Vector, factor: float) -> Vector:
return [x * factor for x in vector]
# Complex nested types
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
def parse_json(data: str) -> JSON:
import json
return json.loads(data)
# Union aliases
Numeric: TypeAlias = int | float
OptionalString: TypeAlias = str | None
# Callable alias
HandlerFunction: TypeAlias = Callable[[str, dict], None]
# Generic alias
from typing import TypeVar
T = TypeVar('T')
Result: TypeAlias = tuple[T, str | None] # (value, error)
def safe_parse(value: str) -> Result[int]:
try:
return (int(value), None)
except ValueError as e:
return (0, str(e))
Function Annotations
Parameter Type Hints
# Basic parameters
def greet(name: str, age: int) -> str:
return f"{name} is {age} years old"
# Default values with type hints
def greet_with_title(
name: str,
title: str = "Mr.",
excited: bool = False
) -> str:
greeting = f"{title} {name}"
return greeting + "!" if excited else greeting
# Multiple types (union)
def process_id(user_id: int | str) -> str:
return str(user_id)
*args and **kwargs Type Hints
# *args: variable positional arguments
def sum_numbers(*args: int) -> int:
return sum(args)
sum_numbers(1, 2, 3, 4) # OK
sum_numbers(1, 2, "3") # Type error!
# **kwargs: variable keyword arguments
def configure(**kwargs: str) -> dict[str, str]:
return kwargs
configure(host="localhost", port="8000") # OK
configure(host="localhost", port=8000) # Type error! port must be str
# Mixed args, kwargs
def complex_function(
required: str,
*args: int,
**kwargs: bool
) -> None:
pass
complex_function("test", 1, 2, 3, flag=True, debug=False) # OK
# Different types for args/kwargs
from typing import Any
def flexible_function(
*args: int | str,
**kwargs: Any
) -> None:
pass
Overload Decorator
Use @overload to provide multiple type signatures:
from typing import overload
# Overload signatures (not implemented)
@overload
def process(data: str) -> str: ...
@overload
def process(data: int) -> int: ...
@overload
def process(data: list[str]) -> list[str]: ...
# Actual implementation (must be compatible with all overloads)
def process(data: str | int | list[str]) -> str | int | list[str]:
if isinstance(data, str):
return data.upper()
elif isinstance(data, int):
return data * 2
else:
return [s.upper() for s in data]
# Type checker knows return type based on input
result1: str = process("hello") # OK, knows it returns str
result2: int = process(42) # OK, knows it returns int
result3: list[str] = process(["a", "b"]) # OK, knows it returns list[str]
# More complex overload example
@overload
def get_value(container: dict[str, int], key: str) -> int: ...
@overload
def get_value(container: list[int], key: int) -> int: ...
def get_value(
container: dict[str, int] | list[int],
key: str | int
) -> int:
return container[key] # type: ignore[index]
Class Type Hints
Instance Variables
class User:
# Instance variable annotations (PEP 526)
name: str
age: int
email: str | None
def __init__(self, name: str, age: int, email: str | None = None) -> None:
self.name = name
self.age = age
self.email = email
# Alternative: annotate in __init__
class Product:
def __init__(self, name: str, price: float) -> None:
self.name: str = name
self.price: float = price
self.stock: int = 0 # Type inferred from default value
Class Variables (ClassVar)
from typing import ClassVar
class Config:
# Class variable (shared across all instances)
api_url: ClassVar[str] = "https://api.example.com"
max_retries: ClassVar[int] = 3
# Instance variable
session_id: str
def __init__(self, session_id: str) -> None:
self.session_id = session_id
# Class variables are accessed on the class
print(Config.api_url) # OK
# Type checker distinguishes class vs instance variables
config = Config("abc123")
config.session_id # OK (instance variable)
config.api_url # OK but mypy may warn (accessing class var from instance)
Property Type Hints
class Circle:
def __init__(self, radius: float) -> None:
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self) -> float:
"""Read-only property"""
import math
return math.pi * self._radius ** 2
# Cached property
from functools import cached_property
class DataProcessor:
def __init__(self, data: list[int]) -> None:
self._data = data
@cached_property
def processed_data(self) -> list[int]:
"""Expensive computation, cached after first access"""
return [x * 2 for x in self._data]
init Annotations
class DatabaseConnection:
def __init__(
self,
host: str,
port: int = 5432,
username: str | None = None,
password: str | None = None,
*, # Force keyword-only arguments after this
timeout: float = 30.0,
ssl: bool = True
) -> None:
self.host = host
self.port = port
self.username = username
self.password = password
self.timeout = timeout
self.ssl = ssl
# Usage
db = DatabaseConnection(
"localhost",
5432,
timeout=60.0, # Keyword-only
ssl=False
)
mypy Configuration
Basic mypy Setup
Create mypy.ini or pyproject.toml for mypy configuration:
mypy.ini:
[mypy]
# Type checking strictness
python_version = 3.9
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
check_untyped_defs = True
strict_equality = True
# Import discovery
namespace_packages = True
ignore_missing_imports = False
# Per-module options
[mypy-tests.*]
disallow_untyped_defs = False
[mypy-third_party_lib.*]
ignore_missing_imports = True
pyproject.toml:
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
strict_equality = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true
Common mypy Flags
Strict mode (all checks enabled):
mypy --strict myfile.py
Common individual flags:
# Require type hints on all functions
mypy --disallow-untyped-defs myfile.py
# Warn about functions that return Any
mypy --warn-return-any myfile.py
# Disallow Any types
mypy --disallow-any-expr myfile.py # Very strict!
# Show error codes
mypy --show-error-codes myfile.py
# Incremental mode (faster)
mypy --incremental myfile.py
# Specific Python version
mypy --python-version 3.9 myfile.py
Useful combinations:
# Recommended starting point
mypy --check-untyped-defs --warn-redundant-casts myfile.py
# Strict for new code
mypy --strict --allow-untyped-calls myfile.py
# CI/CD friendly (no incremental cache)
mypy --no-incremental --show-error-codes mypackage/
Ignoring Errors Appropriately
Use type ignore comments sparingly and document why:
# Ignore specific error on one line
result = legacy_function() # type: ignore[no-untyped-call]
# Ignore all errors on one line (avoid!)
bad_code = something() # type: ignore
# Ignore multiple specific errors
value = complex_operation() # type: ignore[arg-type, return-value]
# Ignore for entire function (last resort)
def legacy_code() -> Any: # type: ignore[no-untyped-def]
pass
# Better: Fix the issue or use proper typing
def proper_code() -> dict[str, Any]:
"""Returns config dict with various value types."""
return {"key": "value"}
When to use type: ignore:
- Interfacing with untyped third-party libraries
- Complex type narrowing that mypy can't infer
- Known false positives (file a mypy issue!)
- Temporary during gradual typing migration
When NOT to use type: ignore:
- To avoid adding type hints
- Because "mypy is wrong" (usually mypy is right!)
- Without documenting the reason
- Instead of fixing the actual type error
Type: ignore Comments Best Practices
# BAD: No error code
result = func() # type: ignore
# GOOD: Specific error code with explanation
# mypy can't infer the narrowed type after this check
result = func() # type: ignore[arg-type] # Known false positive, see issue #123
# GOOD: Document workaround
# TODO: Remove once library adds type stubs
data = legacy_lib.get_data() # type: ignore[no-untyped-call]
# BEST: Fix the issue instead
def properly_typed_function(x: int) -> str:
return str(x)
Best Practices
1. Gradual Typing Strategy
Start with critical code paths, expand gradually:
# Phase 1: Public API functions
def public_api(user_id: int) -> dict[str, Any]:
return _internal_function(user_id)
def _internal_function(user_id): # Not typed yet
return {"id": user_id}
# Phase 2: Expand to internal functions
def _internal_function_typed(user_id: int) -> dict[str, int]:
return {"id": user_id}
# Phase 3: Strict typing everywhere
def fully_typed(user_id: int) -> User:
return User(id=user_id, name="Unknown")
Recommended adoption order:
- Public API functions and methods
- Core business logic
- Internal utilities
- Tests (can be less strict)
- Scripts and one-off code (optional)
2. When to Use Type Hints
Always type hint:
- Public APIs and library functions
- Function signatures (parameters and return types)
- Class attributes and properties
- Complex data structures
Consider type hints:
- Internal/private functions in large codebases
- Helper functions with non-obvious signatures
- Callback functions
Optional type hints:
- Very simple, obvious functions
- Scripts and notebooks
- Prototype code
- One-liners and lambdas
# Always type hint: Public API
def calculate_discount(price: float, discount_percent: float) -> float:
"""Calculate discounted price."""
return price * (1 - discount_percent / 100)
# Optional: Very obvious helper
def _double(x):
return x * 2
# Consider: Less obvious helper (recommended)
def _format_currency(amount: float, currency: str = "USD") -> str:
return f"{amount:.2f} {currency}"
3. Type Hint Readability
Prioritize readability over exhaustive typing:
# BAD: Overly complex, hard to read
def process(
data: dict[str, list[tuple[int, str, dict[str, list[int | str | None]]]]]
) -> list[tuple[str, int]]:
pass
# GOOD: Use type aliases for readability
PersonData = dict[str, list[int | str | None]]
Record = tuple[int, str, PersonData]
DataDict = dict[str, list[Record]]
Result = list[tuple[str, int]]
def process_readable(data: DataDict) -> Result:
pass
# BETTER: Use TypedDict for structured dicts
class PersonData(TypedDict):
age: int
name: str
tags: list[str]
class Record(TypedDict):
id: int
type: str
data: PersonData
def process_structured(data: dict[str, list[Record]]) -> Result:
pass
4. Avoiding Over-Specification
Don't type hint more than necessary:
# BAD: Over-specified (too rigid)
def process_items(items: list[str]) -> list[str]:
return [item.upper() for item in items]
# GOOD: Accept any iterable, return list (more flexible)
from collections.abc import Iterable
def process_items_flexible(items: Iterable[str]) -> list[str]:
return [item.upper() for item in items]
# Now works with lists, tuples, sets, generators, etc.
process_items_flexible(["a", "b"]) # OK
process_items_flexible(("a", "b")) # OK
process_items_flexible(x for x in ["a", "b"]) # OK
# BAD: Forces specific dict implementation
def merge(d1: dict[str, int], d2: dict[str, int]) -> dict[str, int]:
return {**d1, **d2}
# GOOD: Accept any mapping
from collections.abc import Mapping
def merge_flexible(
d1: Mapping[str, int],
d2: Mapping[str, int]
) -> dict[str, int]:
return {**d1, **d2}
Use abstract types from collections.abc:
Iterable[T]instead oflist[T]for inputsSequence[T]when you need indexing/lengthMapping[K, V]instead ofdict[K, V]for inputsMutableMapping[K, V]when you need to modify- Return concrete types like
list,dict
5. Using Protocol for Duck Typing
Prefer Protocol over rigid inheritance:
from typing import Protocol
# BAD: Forces inheritance
class Animal:
def make_sound(self) -> str:
raise NotImplementedError
class Dog(Animal): # Must inherit
def make_sound(self) -> str:
return "Woof"
# GOOD: Duck typing with type safety
class CanMakeSound(Protocol):
def make_sound(self) -> str: ...
class Dog: # No inheritance needed
def make_sound(self) -> str:
return "Woof"
class Car:
def make_sound(self) -> str:
return "Vroom"
def hear_sound(thing: CanMakeSound) -> None:
print(thing.make_sound())
hear_sound(Dog()) # OK
hear_sound(Car()) # OK (anything with make_sound method)
# Real-world example: file-like objects
class SupportsRead(Protocol):
def read(self, n: int = -1) -> str: ...
def process_text_file(file: SupportsRead) -> int:
content = file.read()
return len(content)
# Works with any object that has a read() method
import io
process_text_file(io.StringIO("test")) # OK
with open("file.txt") as f:
process_text_file(f) # OK
6. Type Narrowing
Help mypy understand type narrowing through conditionals:
def process_value(value: int | str | None) -> str:
# mypy tracks type narrowing through conditionals
if value is None:
return "No value"
# After this check, value is int | str in else branch
if isinstance(value, int):
return f"Number: {value}"
# After this check, value is str in else branch
# mypy knows value must be str here
return f"Text: {value.upper()}"
# Use isinstance for type narrowing
def double_if_number(value: int | str) -> int | str:
if isinstance(value, int):
# mypy knows value is int here
return value * 2
# mypy knows value is str here
return value
# Use type guards for custom narrowing
from typing import TypeGuard
def is_list_of_strings(val: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process_list(items: list[Any]) -> None:
if is_list_of_strings(items):
# mypy knows items is list[str] here
for item in items:
print(item.upper()) # OK, item is str
Common Patterns
Forward References (String Annotations)
Use string annotations for forward references:
# Forward reference (class not yet defined)
class Node:
def __init__(self, value: int, next: "Node | None" = None) -> None:
self.value = value
self.next = next
# Python 3.10+: Can use | with quotes
class TreeNode:
def __init__(
self,
value: int,
left: "TreeNode | None" = None,
right: "TreeNode | None" = None
) -> None:
self.value = value
self.left = left
self.right = right
# Python 3.7+: Use from __future__ import annotations
from __future__ import annotations
class ModernNode:
def __init__(self, value: int, next: ModernNode | None = None) -> None:
self.value = value
self.next = next
# No quotes needed with __future__ import!
Circular Type Dependencies
Handle circular dependencies with TYPE_CHECKING:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Imports only used for type checking (not at runtime)
from mypackage.models import User
class Post:
def __init__(self, author: "User") -> None:
self.author = author
# In models.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mypackage.posts import Post
class User:
def __init__(self, posts: list["Post"]) -> None:
self.posts = posts
Generic Container Types
Type hint containers properly:
# Homogeneous lists
def process_names(names: list[str]) -> None:
for name in names:
print(name.upper()) # mypy knows name is str
# Mixed types (use Union)
def process_mixed(items: list[int | str]) -> None:
for item in items:
if isinstance(item, int):
print(item * 2)
else:
print(item.upper())
# Nested containers
def process_matrix(matrix: list[list[float]]) -> float:
return sum(sum(row) for row in matrix)
# Dict with specific key/value types
def process_scores(scores: dict[str, float]) -> float:
return sum(scores.values())
# Complex nested structure
ComplexData = dict[str, list[dict[str, int | str]]]
def process_complex(data: ComplexData) -> None:
pass
Callback Type Hints
Type hint callbacks and handlers:
from typing import Callable
# Simple callback
def run_with_callback(callback: Callable[[int], None]) -> None:
callback(42)
run_with_callback(lambda x: print(x)) # OK
# Event handler
EventHandler = Callable[[str, dict[str, Any]], None]
def register_handler(event: str, handler: EventHandler) -> None:
pass
def on_user_created(event_name: str, data: dict[str, Any]) -> None:
print(f"Event: {event_name}, Data: {data}")
register_handler("user.created", on_user_created)
# Factory function
Factory = Callable[[], T]
def create_default(factory: Factory[T]) -> T:
return factory()
create_default(lambda: []) # Returns list
create_default(lambda: {}) # Returns dict
# Validator function
Validator = Callable[[str], bool]
def validate_email(email: str) -> bool:
return "@" in email
def process_with_validation(value: str, validator: Validator) -> bool:
return validator(value)
process_with_validation("test@example.com", validate_email)
Context Manager Type Hints
Type hint context managers properly:
from typing import Iterator, Generator
from contextlib import contextmanager
# Simple context manager class
class DatabaseConnection:
def __enter__(self) -> "DatabaseConnection":
print("Opening connection")
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
print("Closing connection")
# Generator-based context manager
@contextmanager
def open_file(path: str) -> Iterator[list[str]]:
file = open(path)
try:
yield file.readlines()
finally:
file.close()
# Generic context manager
from typing import Generic
T = TypeVar('T')
@contextmanager
def managed_resource(resource: T) -> Iterator[T]:
try:
yield resource
finally:
print(f"Cleaning up {resource}")
# Async context manager
class AsyncDatabaseConnection:
async def __aenter__(self) -> "AsyncDatabaseConnection":
print("Opening async connection")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
print("Closing async connection")
Anti-Patterns
Using Any Everywhere
# BAD: Defeats purpose of type hints
def process(data: Any) -> Any:
return data
def calculate(x: Any, y: Any) -> Any:
return x + y
# GOOD: Use specific types
def process_user(data: dict[str, str]) -> User:
return User(**data)
def calculate_sum(x: int, y: int) -> int:
return x + y
# ACCEPTABLE: Gradual typing transition
def legacy_function(data: Any) -> dict[str, Any]:
# TODO: Add proper types once interface is stable
return {"result": data}
Over-Complicated Type Hints
# BAD: Unreadable, overly complex
def transform(
data: dict[str, list[tuple[int, str, dict[str, list[int | str | None]]]]]
) -> list[tuple[str, dict[str, list[int]]]]:
pass
# GOOD: Use type aliases
InputRecord = tuple[int, str, dict[str, list[int | str | None]]]
InputData = dict[str, list[InputRecord]]
OutputRecord = tuple[str, dict[str, list[int]]]
OutputData = list[OutputRecord]
def transform_readable(data: InputData) -> OutputData:
pass
# BETTER: Use TypedDict or dataclasses
from dataclasses import dataclass
@dataclass
class Record:
id: int
name: str
metadata: dict[str, list[int | str | None]]
@dataclass
class TransformedRecord:
name: str
values: dict[str, list[int]]
def transform_structured(
data: dict[str, list[Record]]
) -> list[TransformedRecord]:
pass
Ignoring Type Errors Without Justification
# BAD: Silencing errors without understanding
result = risky_operation() # type: ignore
# BAD: Generic ignore
value = complex_call() # type: ignore
# GOOD: Specific error with explanation
# mypy doesn't understand this runtime type check
result = risky_operation() # type: ignore[arg-type] # See issue #456
# GOOD: Document temporary workaround
# TODO: Remove after upgrading to library v2.0 with type stubs
value = legacy_lib.call() # type: ignore[no-untyped-call]
# BEST: Fix the underlying issue
def properly_typed_operation() -> int:
return 42
result = properly_typed_operation() # No ignore needed!
Not Running mypy in CI/CD
# BAD: Only running mypy locally
# Developers forget, type errors slip through
# GOOD: CI/CD pipeline (GitHub Actions example)
# .github/workflows/type-check.yml
"""
name: Type Check
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- run: pip install mypy
- run: mypy src/
"""
# GOOD: pre-commit hook
# .pre-commit-config.yaml
"""
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
hooks:
- id: mypy
additional_dependencies: [types-requests]
"""
Inconsistent Type Hint Usage
# BAD: Inconsistent typing within same module
def get_user(user_id: int) -> dict: # Untyped dict
return {"id": user_id}
def create_user(name, email): # No types at all
return {"name": name, "email": email}
def delete_user(user_id: int) -> bool: # Typed
return True
# GOOD: Consistent typing throughout
class User(TypedDict):
id: int
name: str
email: str
def get_user(user_id: int) -> User:
return {"id": user_id, "name": "Unknown", "email": "unknown@example.com"}
def create_user(name: str, email: str) -> User:
return {"id": 0, "name": name, "email": email}
def delete_user(user_id: int) -> bool:
return True
Tools
mypy - Standard Type Checker
# Install
pip install mypy
# Basic usage
mypy file.py
mypy src/
# With config file
mypy --config-file mypy.ini src/
# Show error codes
mypy --show-error-codes src/
# Strict mode
mypy --strict src/
# Generate HTML report
mypy --html-report mypy-report src/
# Ignore missing imports
mypy --ignore-missing-imports src/
# Check specific Python version
mypy --python-version 3.9 src/
Pros:
- Official type checker
- Excellent documentation
- Large community
- Extensive configuration options
Cons:
- Can be slow on large codebases
- Sometimes conservative type inference
pyright - Microsoft Type Checker
# Install (requires Node.js)
npm install -g pyright
# Or use via pip
pip install pyright
# Basic usage
pyright src/
# Watch mode
pyright --watch src/
# VS Code integration
# Install "Pylance" extension (includes pyright)
Pros:
- Very fast
- Excellent IDE integration (VS Code)
- Advanced type inference
- Good error messages
Cons:
- Different configuration than mypy
- Less community adoption than mypy
pyre - Facebook Type Checker
# Install
pip install pyre-check
# Initialize
pyre init
# Run
pyre check
# Incremental mode
pyre start
pyre incremental
Pros:
- Fast incremental checking
- Good for large codebases
- Advanced features (infer, query)
Cons:
- Less widespread adoption
- Primarily tested on Facebook's codebase
Type Stubs (.pyi files)
Create stub files for untyped code or third-party libraries:
mylib.pyi:
# Stub file (parallel to mylib.py)
def process(data: str) -> int: ...
class Handler:
def handle(self, event: str) -> None: ...
Usage:
# Install type stubs for popular libraries
pip install types-requests
pip install types-redis
pip install types-PyYAML
# Search for available stubs
pip search types-
# Generate stubs from Python code
stubgen -p mypackage -o stubs/
Stub file best practices:
- Use
...for function bodies - Include all public API
- More permissive types than implementation
- Keep in sync with actual code
Checklists
Type Hint Quality Checklist
When reviewing code for type hints, verify:
- All public functions have parameter and return type hints
- Type hints are accurate (match actual behavior)
- Complex types use aliases for readability
- No overuse of
Any(eachAnyis justified) - Collection types are properly specified (e.g.,
list[str]notlist) - Optional parameters use
| NoneorOptional[] -
*argsand**kwargsare typed when used - Class attributes have type annotations
- Properties have return type hints
- No
type: ignorewithout error code and explanation - Forward references use quotes or
__future__.annotations - Union types use
|operator (Python 3.10+) orUnion[] - TypedDict used for structured dicts
- Protocol used instead of forced inheritance where appropriate
mypy Configuration Checklist
For proper mypy setup, ensure:
-
mypy.iniorpyproject.tomlconfiguration exists - Python version specified in config
-
warn_return_anyenabled -
warn_unused_configsenabled -
no_implicit_optionalenabled -
warn_redundant_castsenabled -
warn_unused_ignoresenabled -
strict_equalityenabled - Per-module overrides for tests and third-party code
- CI/CD pipeline runs mypy
- Pre-commit hook for mypy (optional but recommended)
- Team agrees on strictness level
Gradual Typing Migration Checklist
When adding types to existing codebase:
- Start with most critical/public API functions
- Add types to new code immediately
- Focus on one module at a time
- Use
Anytemporarily for complex migrations - Run mypy incrementally (not
--strictinitially) - Document known type issues in TODO comments
- Create type stubs for untyped dependencies
- Enable stricter mypy options gradually
- Update documentation with type examples
- Train team on type hint best practices
Examples
See the examples/ directory for comprehensive examples:
examples/basic_types.py- Basic type hint usageexamples/advanced_types.py- Generic types, protocols, TypedDictexamples/good_vs_bad.py- Common patterns vs anti-patternsexamples/mypy_config_examples/- Sample mypy configurations
Templates
Template: mypy.ini Configuration
Located at: templates/mypy.ini
Use this as a starting point for mypy configuration in new projects.
Template: Type Stub File
Located at: templates/stub_file.pyi
Use this template when creating type stubs for untyped code.
Template: Typed Class
Located at: templates/typed_class.py
Example of a well-typed Python class with all common patterns.
Related Skills
- python-testing-patterns: Type hints improve testability and test clarity
- python-code-style: Type hints are part of PEP 8 style guidelines
- python-async-patterns: Async functions require special type hint considerations
References
PEPs (Python Enhancement Proposals)
- PEP 484: Type Hints (foundation)
- PEP 526: Syntax for Variable Annotations
- PEP 544: Protocols (structural subtyping)
- PEP 585: Type Hinting Generics In Standard Collections (Python 3.9+)
- PEP 586: Literal Types
- PEP 589: TypedDict
- PEP 591: Final qualifier
- PEP 604: Union operator
|(Python 3.10+) - PEP 612: Parameter Specification Variables
- PEP 613: TypeAlias annotation
- PEP 647: Type Guards
Official Documentation
- Python typing module: https://docs.python.org/3/library/typing.html
- mypy documentation: https://mypy.readthedocs.io/
- Type hints cheat sheet: https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html
External Resources
- Real Python typing guide: https://realpython.com/python-type-checking/
- typing_extensions package: https://pypi.org/project/typing-extensions/
Version: 1.0 Last Updated: 2025-12-24 Target Python Version: 3.9+ Maintainer: Uncle Duke (Python Development Agent)
Didn't find tool you were looking for?