Python Type Hints — Static Typing for Python
Type hints document expected types and enable static analysis. They catch bugs before runtime, improve code readability, and provide better IDE support. Python uses gradual typing, so you can adopt type hints incrementally.
Learning Objectives
- Add type hints to functions and variables
- Use advanced types (Union, Optional, TypeVar, Generic)
- Define protocols and typed structures
- Run mypy for static type checking
- Apply gradual typing to existing codebases
- Understand when and how to use type hints effectively
Basic Type Hints
def greet(name: str) -> str:
"""Greet a person by name."""
return f"Hello, {name}"
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
# Variable annotations
age: int = 25
name: str = "Alice"
scores: list[int] = [95, 87, 91]
is_active: bool = True
Function Parameter Types
# Simple types
def process(data: str, count: int, rate: float) -> bool:
return True
# Multiple types with Union
def format_value(value: int | str) -> str: # Python 3.10+
return str(value)
# Using Union (compatible with older Python)
from typing import Union
def format_value(value: Union[int, str]) -> str:
return str(value)
# No return value
def log_message(message: str) -> None:
print(message)
# Return any type
def get_value(key: str) -> any: # Not recommended, but possible
return data[key]
Advanced Types
Optional — Value or None
from typing import Optional
# Optional[X] is shorthand for Union[X, None]
def find_user(user_id: int) -> Optional[dict]:
"""Find user by ID, return None if not found."""
users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return users.get(user_id)
def process_data(data: Optional[list[str]] = None) -> int:
"""Process data, defaulting to empty list."""
if data is None:
data = []
return len(data)
# Python 3.10+ syntax
def find_user(user_id: int) -> dict | None:
return users.get(user_id)
TypeVar — Generic Types
from typing import TypeVar, List
T = TypeVar('T') # Declares a type variable
def first(items: List[T]) -> T:
"""Return the first item in a list."""
return items[0]
# Usage — type is inferred
result = first([1, 2, 3]) # result is int
result = first(["a", "b"]) # result is str
# Multiple type variables
T = TypeVar('T')
K = TypeVar('K')
def get_first_and_key(items: List[T], key: K) -> tuple[T, K]:
return items[0], key
# Bounded TypeVar
from typing import TypeVar
Numeric = TypeVar('Numeric', int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
add(1, 2) # OK
add(1.5, 2.5) # OK
add("a", "b") # mypy error
TypedDict — Typed Dictionaries
from typing import TypedDict, Required, NotRequired
class UserDict(TypedDict):
"""Typed dictionary for user data."""
name: str
age: int
email: str
is_active: bool
# Usage
user: UserDict = {
"name": "Alice",
"age": 30,
"email": "alice@example.com",
"is_active": True
}
# Optional fields
class ProductDict(TypedDict, total=False):
"""Product with optional fields."""
name: str
price: float
description: NotRequired[str]
rating: NotRequired[float]
# Partial (all fields optional)
from typing import TypedDict, Required
class UpdateUser(TypedDict, total=False):
name: str
email: str
Callable — Function Types
from typing import Callable, Awaitable
# Simple callable
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
add = lambda x, y: x + y
result = apply(add, 2, 3) # 5
# Callable with no arguments
def run_periodically(func: Callable[[], None], interval: float) -> None:
while True:
func()
time.sleep(interval)
# Callable that returns Optional
def find_and_process(
func: Callable[[str], Optional[dict]],
key: str
) -> Optional[dict]:
return func(key)
# Async callable
async def async_apply(
func: Callable[[int], Awaitable[int]],
value: int
) -> int:
return await func(value)
Tuple and FrozenSet
from typing import Tuple, FrozenSet
# Fixed-length tuple
def get_coordinates() -> Tuple[float, float]:
return (40.7128, -74.0060)
# Variable-length tuple
def get_all_scores() -> Tuple[int, ...]:
return (85, 92, 78, 95)
# FrozenSet (immutable set)
def get_unique_ids(ids: FrozenSet[int]) -> int:
return len(ids)
Generic Classes
from typing import TypeVar, Generic, List
T = TypeVar('T')
class Stack(Generic[T]):
"""Generic stack data structure."""
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:
return self._items[-1]
def is_empty(self) -> bool:
return len(self._items) == 0
def __len__(self) -> int:
return len(self._items)
# Type is inferred
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop()
str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_value: str = str_stack.pop()
Generic with Multiple Types
from typing import TypeVar, Generic, Dict, List
K = TypeVar('K')
V = TypeVar('V')
class Registry(Generic[K, V]):
"""Generic registry mapping keys to values."""
def __init__(self) -> None:
self._data: Dict[K, V] = {}
def register(self, key: K, value: V) -> None:
self._data[key] = value
def get(self, key: K) -> V:
return self._data[key]
def get_all(self) -> List[V]:
return list(self._data.values())
# Usage
user_registry: Registry[int, str] = Registry()
user_registry.register(1, "Alice")
user_registry.register(2, "Bob")
config_registry: Registry[str, int] = Registry()
config_registry.register("port", 8080)
config_registry.register("timeout", 30)
Protocol — Structural Subtyping
Protocols define interfaces without requiring inheritance:
from typing import Protocol
class Drawable(Protocol):
"""Protocol for objects that can be drawn."""
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "O"
class Square:
def draw(self) -> str:
return "[]"
class Triangle:
def draw(self) -> str:
return "/\\"
def render(shape: Drawable) -> str:
"""Render any drawable shape."""
return shape.draw()
# These all work — no inheritance needed!
render(Circle()) # "O"
render(Square()) # "[]"
render(Triangle()) # "/\\"
Real-World Protocol Example
from typing import Protocol, runtime_checkable
@runtime_checkable
class Database(Protocol):
"""Protocol for database-like objects."""
def execute(self, query: str) -> list: ...
def close(self) -> None: ...
class SQLiteDB:
def __init__(self, path: str):
self.path = path
def execute(self, query: str) -> list:
# SQLite implementation
return []
def close(self) -> None:
pass
class PostgreSQLDB:
def __init__(self, connection_string: str):
self.connection_string = connection_string
def execute(self, query: str) -> list:
# PostgreSQL implementation
return []
def close(self) -> None:
pass
def process_data(db: Database) -> None:
"""Process data using any database implementation."""
results = db.execute("SELECT * FROM data")
# Process results
db.close()
# Both work!
process_data(SQLiteDB("app.db"))
process_data(PostgreSQLDB("postgresql://localhost/mydb"))
Type Aliases
from typing import TypeAlias, List, Dict, Optional
# Define type aliases for complex types
Vector: TypeAlias = List[float]
Matrix: TypeAlias = List[Vector]
UserDict: TypeAlias = Dict[str, Optional[str]]
JSON: TypeAlias = Dict[str, any]
# Use aliases for cleaner code
def dot_product(a: Vector, b: Vector) -> float:
return sum(x * y for x, y in zip(a, b))
def matrix_multiply(a: Matrix, b: Matrix) -> Matrix:
# Complex matrix multiplication
pass
def process_user(user: UserDict) -> str:
name = user.get("name", "Unknown")
return f"Processing {name}"
mypy Static Checking
Basic mypy Usage
# Install: pip install mypy
# Run: mypy your_script.py
def add(a: int, b: int) -> int:
return a + b
add("hello", "world") # mypy error: Argument 1 has type "str", expected "int"
mypy Configuration
# mypy.ini or setup.cfg
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
# Per-module overrides
[mymodule.*]
disallow_untyped_defs = False
Strict Mode
# Run mypy in strict mode
mypy --strict your_script.py
# This checks:
# - All functions have type annotations
# - No implicit Any types
# - No untyped decorators
# - etc.
Common mypy Errors
from typing import List, Optional
# Error: Incompatible return type
def get_name() -> str:
return 123 # error: Incompatible return value type (got "int", expected "str")
# Error: Missing return statement
def process(x: int) -> str: # error: Missing return statement
if x > 0:
return "positive"
# Error: Incompatible types
def add(x: int, y: int) -> int:
return x + y
result: str = add(1, 2) # error: Incompatible types in assignment
# Error: Argument type
def greet(name: str) -> str:
return f"Hello, {name}"
greet(123) # error: Argument 1 to "greet" has incompatible type "int"; expected "str"
# Error: Optional handling
def find(x: int) -> Optional[int]:
return x if x > 0 else None
value: int = find(-1) # error: Incompatible types in assignment
# Fix: value = find(-1) or 0
Gradual Typing
Add type hints incrementally to existing code:
# Step 1: Add hints to function signatures
def process(data, options=None): # Before
pass
def process(data: dict, options: Optional[dict] = None) -> dict: # After
pass
# Step 2: Use mypy:ignore for complex sections
def legacy_function(data): # type: ignore
# Untyped legacy code
pass
# Step 3: Add hints to internal functions
def _helper(x: int, y: int) -> int:
return x + y
# Step 4: Gradually remove type: ignore comments
Type Stub Files
# For third-party libraries without type hints
# Create file: mylib.pyi
from typing import Any, Optional
def my_function(arg: str, option: bool = True) -> dict[str, Any]: ...
class MyClass:
def __init__(self, value: int) -> None: ...
def method(self, arg: str) -> Optional[int]: ...
Real-World Examples
Example 1: Typing a Function Library
from typing import TypeVar, Generic, List, Optional, Callable, Any
from dataclasses import dataclass
T = TypeVar('T')
R = TypeVar('R')
@dataclass
class Result(Generic[T]):
"""Result type for operations that can fail."""
success: bool
value: Optional[T] = None
error: Optional[str] = None
@classmethod
def ok(cls, value: T) -> 'Result[T]':
return cls(success=True, value=value)
@classmethod
def fail(cls, error: str) -> 'Result[T]':
return cls(success=False, error=error)
def map_result(result: Result[T], func: Callable[[T], R]) -> Result[R]:
"""Map a function over a Result value."""
if result.success and result.value is not None:
return Result.ok(func(result.value))
return Result.fail(result.error or "Unknown error")
def chain_results(
results: List[Result[T]],
func: Callable[[T], Result[R]]
) -> Result[R]:
"""Chain multiple Result operations."""
for result in results:
if not result.success:
return Result.fail(result.error or "Operation failed")
if result.value is not None:
mapped = func(result.value)
if not mapped.success:
return mapped
return Result.fail("No results to process")
# Usage
def parse_int(s: str) -> Result[int]:
try:
return Result.ok(int(s))
except ValueError:
return Result.fail(f"Cannot parse '{s}' as integer")
def double(n: int) -> Result[int]:
return Result.ok(n * 2)
result = parse_int("42")
mapped = map_result(result, double)
print(mapped) # Result(success=True, value=84, error=None)
result = parse_int("not a number")
mapped = map_result(result, double)
print(mapped) # Result(success=False, value=None, error="Cannot parse 'not a number' as integer")
Example 2: Typed API Client
from typing import TypeVar, Generic, List, Optional, Dict, Any
from dataclasses import dataclass
import requests
T = TypeVar('T')
@dataclass
class APIResponse(Generic[T]):
status_code: int
data: Optional[T]
error: Optional[str] = None
class APIClient(Generic[T]):
"""Generic API client with typed responses."""
def __init__(self, base_url: str, response_type: type):
self.base_url = base_url
self.response_type = response_type
self.session = requests.Session()
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> APIResponse[T]:
try:
response = self.session.get(
f"{self.base_url}{endpoint}",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
typed_data = self.response_type(**data) if isinstance(data, dict) else data
return APIResponse(
status_code=response.status_code,
data=typed_data
)
except Exception as e:
return APIResponse(
status_code=0,
data=None,
error=str(e)
)
# Define your data types
@dataclass
class User:
id: int
name: str
email: str
# Usage
client = APIClient("https://api.example.com", User)
response = client.get("/users/1")
if response.data:
print(f"User: {response.data.name}")
Example 3: Type-Safe Configuration
from typing import TypedDict, NotRequired, Required
from pathlib import Path
class DatabaseConfig(TypedDict, total=False):
host: str
port: int
username: str
password: str
database: str
ssl: bool
class AppConfig(TypedDict):
"""Application configuration with required and optional fields."""
app_name: str
debug: bool
database: DatabaseConfig
log_level: NotRequired[str]
max_connections: NotRequired[int]
def load_config() -> AppConfig:
"""Load and validate configuration."""
config: AppConfig = {
"app_name": "MyApp",
"debug": False,
"database": {
"host": "localhost",
"port": 5432,
"username": "admin",
"password": "secret",
"database": "mydb",
}
}
return config
def process_config(config: AppConfig) -> None:
"""Process configuration with type safety."""
print(f"App: {config['app_name']}")
print(f"Debug: {config['debug']}")
db_config = config['database']
print(f"DB Host: {db_config.get('host', 'localhost')}")
print(f"DB Port: {db_config.get('port', 5432)}")
Common Mistakes
| Mistake | Problem | Solution |
|---|---|---|
Using Any too much | Defeats purpose of type hints | Use specific types or TypeVar |
| Not handling Optional | Runtime None errors | Check for None before using |
| Over-annotating simple code | Clutters code | Only annotate public APIs |
| Ignoring mypy errors | Miss real bugs | Fix errors or use # type: ignore with reason |
| Not using TypeVar | Generic functions lose type info | Use TypeVar for generic code |
| Mixing typing styles | Confusing code | Be consistent across codebase |
Best Practices
# 1. Annotate public APIs first
def public_function(arg: str, option: bool = True) -> dict[str, Any]:
"""This function's signature is fully typed."""
pass
def _private_helper(x): # Internal functions can be untyped initially
pass
# 2. Use Optional for nullable values
def find_user(user_id: int) -> Optional[User]:
return db.get_user(user_id)
# 3. Use TypeVar for generics
T = TypeVar('T')
def first(items: list[T]) -> T:
return items[0]
# 4. Use Protocol for duck typing
class Writable(Protocol):
def write(self, data: str) -> None: ...
def save_to(file: Writable, data: str) -> None:
file.write(data)
# 5. Use TypeAlias for complex types
from typing import TypeAlias
JSON: TypeAlias = dict[str, Any]
UserList: TypeAlias = list[dict[str, str]]
# 6. Run mypy regularly
# mypy --strict your_package/
Key Takeaways
- Add type hints for better IDE support, documentation, and static analysis
- Use
Optional[X](orX | Nonein 3.10+) for values that can be None - Use
TypeVarfor generic functions and classes that work with multiple types - Use
Protocolfor structural subtyping — define interfaces without inheritance - Run
mypyto catch type errors statically before runtime - Adopt type hints gradually — start with public APIs and critical functions
- Type hints are documentation — they make code self-documenting and easier to understand