Python Typing
Master type hints and static type checking for safer, more maintainable Python code
Overview
Type hints (also called type annotations) allow you to specify the expected types of variables, function parameters, and return values in your Python code. While Python remains dynamically typed at runtime, type hints enable static type checkers like mypy and pyright to catch type-related errors before your code runs.
Think of type hints as documentation that your IDE and tools can understand and verify. They make your code self-documenting, catch bugs early, and improve code completion in modern IDEs.
Why Use Type Hints?
Benefits
Catch Errors Early
def calculate_discount(price: float, discount: float) -> float:
return price * discount
# Type checker catches this before runtime
result = calculate_discount("100", 0.1) # Error: Expected float, got strBetter IDE Support
Your IDE can provide accurate autocomplete and inline documentation when it knows the types.
Self-Documenting Code
Type hints make function signatures clearer without needing to read docstrings.
# Without types - what do these parameters mean?
def process_user(data, active, tags):
pass
# With types - crystal clear
def process_user(data: dict[str, Any], active: bool, tags: list[str]) -> User:
passEasier Refactoring
Type checkers help you find all the places that need updating when you change a function signature or data structure.
Team Collaboration
Type hints establish a contract between code components, making it easier for teams to work together on large codebases.
Basic Type Annotations
Simple Types
# Variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.8
is_active: bool = True
# Function parameters and return types
def greet(name: str) -> str:
return f"Hello, {name}!"
# No return value
def log_message(message: str) -> None:
print(message)The None Type
from typing import Optional
# Function that might return None
def find_user(user_id: int) -> Optional[str]:
if user_id == 1:
return "Alice"
return None
# Equivalent modern syntax (Python 3.10+)
def find_user(user_id: int) -> str | None:
if user_id == 1:
return "Alice"
return NoneCollection Types
Built-in Collection Annotations
Python 3.9+ allows using built-in collection types directly for annotations:
# Lists
def process_names(names: list[str]) -> list[str]:
return [name.upper() for name in names]
# Dictionaries
def get_user_scores() -> dict[str, int]:
return {"Alice": 95, "Bob": 87}
# Sets
def unique_tags() -> set[str]:
return {"python", "typing", "tutorial"}
# Tuples with fixed size
def get_coordinates() -> tuple[float, float]:
return (40.7128, -74.0060)
# Tuples with variable size
def get_values() -> tuple[int, ...]:
return (1, 2, 3, 4, 5)The typing Module (Pre-3.9 or Complex Types)
from typing import List, Dict, Set, Tuple
# Same as above, but works in Python 3.7-3.8
def process_names(names: List[str]) -> List[str]:
return [name.upper() for name in names]Nested Collections
# List of dictionaries
def get_users() -> list[dict[str, str]]:
return [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
]
# Dictionary with list values
def get_user_tags() -> dict[str, list[str]]:
return {
"Alice": ["python", "docker"],
"Bob": ["kubernetes", "terraform"],
}Optional and Union Types
Optional Values
from typing import Optional
# These two are equivalent
def find_user(user_id: int) -> Optional[str]:
pass
def find_user(user_id: int) -> str | None:
passUnion Types
from typing import Union
# Accept multiple types (old syntax)
def process_id(id: Union[int, str]) -> str:
return str(id)
# Modern syntax (Python 3.10+)
def process_id(id: int | str) -> str:
return str(id)
# Multiple unions
def parse_value(value: int | float | str | None) -> float:
if value is None:
return 0.0
return float(value)Type Aliases
Type aliases make complex types more readable and reusable.
from typing import TypeAlias
# Simple alias
UserID: TypeAlias = int
Username: TypeAlias = str
def get_user(user_id: UserID) -> Username:
return "Alice"
# Complex alias
UserData: TypeAlias = dict[str, int | str | list[str]]
def process_user(data: UserData) -> None:
print(data)
# Usage
user: UserData = {
"name": "Alice",
"age": 30,
"tags": ["python", "mlops"]
}NewType
NewType creates a distinct type that prevents accidental mixing:
from typing import NewType
# Create distinct types
UserID = NewType('UserID', int)
OrderID = NewType('OrderID', int)
def get_user(user_id: UserID) -> str:
return f"User {user_id}"
def get_order(order_id: OrderID) -> str:
return f"Order {order_id}"
# Create values
user_id = UserID(123)
order_id = OrderID(456)
# This works
get_user(user_id)
# Type checker catches this error
get_user(order_id) # Error: Expected UserID, got OrderID
get_user(123) # Error: Expected UserID, got intGeneric Types and TypeVar
Generics allow you to write functions and classes that work with any type while maintaining type safety.
Basic TypeVar
from typing import TypeVar
# Define a type variable
T = TypeVar('T')
def first_element(items: list[T]) -> T:
return items[0]
# Type checker infers return type based on input
numbers: list[int] = [1, 2, 3]
first_num: int = first_element(numbers) # Returns int
names: list[str] = ["Alice", "Bob"]
first_name: str = first_element(names) # Returns strConstrained TypeVar
from typing import TypeVar
# Constrain to specific types
NumberType = TypeVar('NumberType', int, float)
def add_numbers(a: NumberType, b: NumberType) -> NumberType:
return a + b
# Works with int or float
result1 = add_numbers(1, 2) # OK: int
result2 = add_numbers(1.5, 2.5) # OK: float
result3 = add_numbers("a", "b") # Error: str not allowedBounded TypeVar
from typing import TypeVar
class Animal:
def speak(self) -> str:
return "Some sound"
class Dog(Animal):
def speak(self) -> str:
return "Woof"
# T must be Animal or a subclass
T = TypeVar('T', bound=Animal)
def make_speak(animal: T) -> T:
print(animal.speak())
return animal
dog = Dog()
make_speak(dog) # OK: Dog is subclass of AnimalGeneric Classes
from typing import Generic, TypeVar
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()
# Create type-specific stacks
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_stack.push(123) # Error: Expected str, got intProtocol and Structural Subtyping
Protocols define interfaces based on structure, not inheritance (duck typing with type safety).
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
class Circle:
def draw(self) -> str:
return "Drawing circle"
class Square:
def draw(self) -> str:
return "Drawing square"
# Function accepts anything with a draw() method
def render(shape: Drawable) -> None:
print(shape.draw())
# Both work without inheriting from Drawable
render(Circle()) # OK
render(Square()) # OKProtocol with Properties
from typing import Protocol
class Sized(Protocol):
@property
def size(self) -> int:
...
class File:
def __init__(self, content: str) -> None:
self._content = content
@property
def size(self) -> int:
return len(self._content)
def log_size(obj: Sized) -> None:
print(f"Size: {obj.size}")
file = File("hello")
log_size(file) # OK: File implements the Sized protocolThe Any Type
Any disables type checking for a value. Use sparingly.
from typing import Any
def process_data(data: Any) -> Any:
# Type checker won't complain about anything here
return data.whatever.method() # No error even if this is wrong
# Better: Use specific types when possible
def process_user(data: dict[str, Any]) -> str:
# At least we know it's a dict
return data["name"]Runtime Type Checking
Type hints don't affect runtime behavior, but you can check them programmatically.
from typing import get_type_hints
def calculate_price(base: float, tax: float) -> float:
return base * (1 + tax)
# Get type hints as a dictionary
hints = get_type_hints(calculate_price)
print(hints)
# Output: {'base': <class 'float'>, 'tax': <class 'float'>, 'return': <class 'float'>}Integration with Static Type Checkers
Using mypy
Install mypy:
pip install mypyCheck your code:
mypy your_script.pyExample output:
your_script.py:10: error: Argument 1 to "calculate_discount" has incompatible type "str"; expected "float"
Found 1 error in 1 file (checked 1 source file)Using pyright
Install pyright:
npm install -g pyright
# or
pip install pyrightCheck your code:
pyright your_script.pyConfiguration
mypy.ini or pypy.ini:
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = Truepyproject.toml:
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
[tool.pyright]
pythonVersion = "3.10"
typeCheckingMode = "strict"Best Practices
Start Gradually
You don't need to type everything at once. Start with public APIs and gradually add types to internal code.
# Start here: Type public function signatures
def calculate_price(base: float, tax: float) -> float:
return _apply_discount(base, tax)
# Can add types to private functions later
def _apply_discount(base, tax):
return base * (1 - tax)Use Type Checkers in CI/CD
Add type checking to your continuous integration pipeline:
# .github/workflows/ci.yml
- name: Type check with mypy
run: mypy src/Prefer Specific Types Over Any
# Bad: Too vague
def process(data: Any) -> Any:
return data["result"]
# Good: Specific and safe
def process(data: dict[str, int]) -> int:
return data["result"]Use Union Sparingly
If you find yourself using many unions, consider refactoring:
# Bad: Too many unions
def process(value: int | str | list | dict | None) -> str:
pass
# Better: Use a common protocol or base class
from typing import Protocol
class Serializable(Protocol):
def to_string(self) -> str:
...
def process(value: Serializable) -> str:
return value.to_string()Annotate Complex Return Types
# Unclear
def get_user():
return {"name": "Alice", "scores": [95, 87, 92]}
# Clear
def get_user() -> dict[str, str | list[int]]:
return {"name": "Alice", "scores": [95, 87, 92]}Common Pitfalls
Mutable Default Arguments
# Bad: Mutable default argument
def add_item(items: list[str] = []) -> list[str]:
items.append("new")
return items
# Good: Use None and create new list
def add_item(items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append("new")
return itemsCircular Imports
Use forward references for types defined later:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from my_module import MyClass
def process(obj: MyClass) -> None:
passOver-Specifying Types
# Bad: Too specific, hard to extend
def process(numbers: list[int]) -> list[int]:
return [n * 2 for n in numbers]
# Good: More flexible with protocols
from typing import Sequence
def process(numbers: Sequence[int]) -> list[int]:
return [n * 2 for n in numbers]Advanced Topics
Callable Types
from typing import Callable
# Function that takes a function
def apply_operation(
value: int,
operation: Callable[[int], int]
) -> int:
return operation(value)
def double(x: int) -> int:
return x * 2
result = apply_operation(5, double) # 10Literal Types
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("INFO") # OK
set_log_level("TRACE") # Error: Not a valid literalTypedDict
from typing import TypedDict
class UserDict(TypedDict):
name: str
age: int
email: str
def create_user(user: UserDict) -> None:
print(f"Creating user: {user['name']}")
# Valid
user: UserDict = {"name": "Alice", "age": 30, "email": "alice@example.com"}
create_user(user)
# Type checker catches missing keys
invalid_user: UserDict = {"name": "Bob"} # Error: Missing 'age' and 'email'Summary
Type hints are a powerful tool for writing more maintainable Python code. They provide documentation, enable better tooling, and catch errors early. Start by adding types to your function signatures, then gradually expand to more complex scenarios.
Next Steps
Ready to practice? Head to the Python Typing Lab to work through hands-on exercises.