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
- 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)
- 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}"
- 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
- 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 of list[T] for inputs
-
Sequence[T] when you need indexing/length
-
Mapping[K, V] instead of dict[K, V] for inputs
-
MutableMapping[K, V] when you need to modify
-
Return concrete types like list , dict
- 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
- 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 (each Any is justified)
-
Collection types are properly specified (e.g., list[str] not list )
-
Optional parameters use | None or Optional[]
-
*args and **kwargs are typed when used
-
Class attributes have type annotations
-
Properties have return type hints
-
No type: ignore without error code and explanation
-
Forward references use quotes or future.annotations
-
Union types use | operator (Python 3.10+) or Union[]
-
TypedDict used for structured dicts
-
Protocol used instead of forced inheritance where appropriate
mypy Configuration Checklist
For proper mypy setup, ensure:
-
mypy.ini or pyproject.toml configuration exists
-
Python version specified in config
-
warn_return_any enabled
-
warn_unused_configs enabled
-
no_implicit_optional enabled
-
warn_redundant_casts enabled
-
warn_unused_ignores enabled
-
strict_equality enabled
-
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 Any temporarily for complex migrations
-
Run mypy incrementally (not --strict initially)
-
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 usage
-
examples/advanced_types.py
-
Generic types, protocols, TypedDict
-
examples/good_vs_bad.py
-
Common patterns vs anti-patterns
-
examples/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)