Type Hints
Type hints, introduced in PEP 484, enable static type checking and improve code clarity. They help catch type-related errors early and make code more self-documenting.
Why Use Type Hints?
- Early Error Detection: Catch type-related bugs before runtime
- Better IDE Support: Enhanced autocomplete and refactoring
- Self-Documenting Code: Types serve as inline documentation
- Improved Maintainability: Easier to understand and modify code
- Tool Integration: Better support from linters and static analyzers
Basic Type Hints
Function Annotations
def greet(name: str) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"
def add_numbers(a: int, b: int) -> int:
"""Add two integers."""
return a + b
def calculate_average(numbers: list[float]) -> float:
"""Calculate the average of a list of numbers."""
return sum(numbers) / len(numbers)
Variable Annotations
# Python 3.6+ variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.6
is_student: bool = True
# For variables without initial values
user_id: int
config: dict[str, str]
Built-in Types
Basic Types
def process_data(
text: str,
count: int,
ratio: float,
is_valid: bool
) -> None:
print(f"Processing {text} with {count} items")
Collection Types (Python 3.9+)
# Lists
def process_names(names: list[str]) -> list[str]:
return [name.title() for name in names]
# Dictionaries
def get_user_info() -> dict[str, str]:
return {"name": "Alice", "email": "alice@example.com"}
# Sets
def get_unique_ids(data: list[int]) -> set[int]:
return set(data)
# Tuples
def get_coordinates() -> tuple[float, float]:
return (10.5, 20.3)
# Tuple with variable length
def get_scores() -> tuple[int, ...]:
return (85, 92, 78, 96)
Optional and Union Types
from typing import Optional, Union
# Optional (can be None)
def find_user(user_id: int) -> Optional[str]:
"""Find a user by ID, return None if not found."""
# Implementation here
return None
# Union types (Python 3.10+)
def handle_input(value: str | int) -> str:
"""Handle data that can be either string or integer."""
return str(value)
# Older syntax (before Python 3.10)
def handle_data(data: Union[str, int]) -> str:
return str(data)
Advanced Type Hints
Generic Types
from typing import TypeVar, Generic
T = TypeVar('T')
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:
return self._items.pop()
def is_empty(self) -> bool:
return len(self._items) == 0
# Usage
string_stack: Stack[str] = Stack()
string_stack.push("hello")
int_stack: Stack[int] = Stack()
int_stack.push(42)
Callable Types
from typing import Callable
def apply_operation(
numbers: list[int],
operation: Callable[[int], int]
) -> list[int]:
"""Apply an operation to each number in the list."""
return [operation(num) for num in numbers]
# Usage
def square(x: int) -> int:
return x ** 2
result = apply_operation([1, 2, 3, 4], square)
Protocol (Structural Typing)
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing a circle")
class Square:
def draw(self) -> None:
print("Drawing a square")
def render_shape(shape: Drawable) -> None:
"""Render any drawable shape."""
shape.draw()
# Both Circle and Square can be used
render_shape(Circle())
render_shape(Square())
Literal Types
from typing import Literal
def set_mode(mode: Literal["read", "write", "append"]) -> None:
"""Set file operation mode."""
print(f"Mode set to: {mode}")
# Valid calls
set_mode("read")
set_mode("write")
# This would cause a type error
# set_mode("invalid") # Type error
Type Aliases
Create aliases for complex types:
# Type aliases
UserId = int
UserData = dict[str, str | int]
Coordinates = tuple[float, float]
def create_user(user_id: UserId, data: UserData) -> None:
print(f"Creating user {user_id} with data {data}")
def calculate_distance(point1: Coordinates, point2: Coordinates) -> float:
x1, y1 = point1
x2, y2 = point2
return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
Modern Type Hints (Python 3.9+)
Built-in Collections
# Python 3.9+ - use built-in types directly
def process_data(items: list[str]) -> dict[str, int]:
return {item: len(item) for item in items}
def merge_configs(
config1: dict[str, str],
config2: dict[str, str]
) -> dict[str, str]:
return {**config1, **config2}
# Before Python 3.9 - needed typing module
from typing import List, Dict
def process_data_old(items: List[str]) -> Dict[str, int]:
return {item: len(item) for item in items}
Union Operator (Python 3.10+)
# Python 3.10+ - use | for unions
def process_value(value: str | int | float) -> str:
return str(value)
def find_item(items: list[str], query: str) -> str | None:
for item in items:
if query in item:
return item
return None
# Before Python 3.10
from typing import Union
def process_value_old(value: Union[str, int, float]) -> str:
return str(value)
Type Checking with Mypy
Install and use Mypy for static type checking:
pip install mypy
mypy your_script.py
conda install -c conda-forge mypy
# or
mamba install -c conda-forge mypy
mypy your_script.py
uv add mypy
mypy your_script.py
poetry add mypy --group dev
mypy your_script.py
Mypy Configuration
Create a mypy.ini file:
[mypy]
python_version = 3.8
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
disallow_untyped_decorators = True
Or configure in pyproject.toml:
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
# Per-module options
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
Best Practices
1. Start Gradually
# Start with function signatures
def calculate_total(prices: list[float]) -> float:
return sum(prices)
# Add variable annotations where helpful
total_price: float = calculate_total([10.99, 5.50, 3.25])
2. Use Specific Types
# Good - specific
def get_user_ages() -> dict[str, int]:
return {"alice": 30, "bob": 25}
# Less helpful - too generic
def get_user_ages() -> dict:
return {"alice": 30, "bob": 25}
3. Handle Optional Values
def find_user(user_id: int) -> Optional[dict[str, str]]:
# Always handle the None case
user = database.get_user(user_id)
if user is None:
return None
return {"name": user.name, "email": user.email}
4. Use Type Aliases for Complex Types
# Define complex types once
JSONData = dict[str, str | int | float | bool | None]
APIResponse = dict[str, JSONData | list[JSONData]]
def process_api_response(response: APIResponse) -> None:
# Implementation here
pass
Common Patterns
Factory Functions
from typing import TypeVar
T = TypeVar('T')
def create_list(item_type: type[T], *items: T) -> list[T]:
return list(items)
# Usage
numbers = create_list(int, 1, 2, 3) # list[int]
names = create_list(str, "a", "b", "c") # list[str]
Context Managers
from typing import Iterator
from contextlib import contextmanager
@contextmanager
def database_transaction() -> Iterator[None]:
# Setup
begin_transaction()
try:
yield
except Exception:
rollback_transaction()
raise
else:
commit_transaction()
Integration with IDEs
Most modern IDEs support type hints:
- VS Code: Install Python extension for full type checking support
- PyCharm: Built-in type checking and inference
- Vim/Neovim: Use language servers like Pylsp or Pyright
Type hints make your code more professional, maintainable, and less prone to bugs. Start using them in new code and gradually add them to existing projects.