Type Hints & Annotations
Python is dynamically typed — you never have to declare that a variable is an int or a str. But since Python 3.5, you can add type hints that document expected types and let tools catch bugs before your code runs.
Type hints are optional, don't affect runtime behavior, and make your code dramatically easier to understand.
Basic Type Hints
Variable Annotations
name: str = "Alice" age: int = 30 temperature: float = 98.6 is_active: bool = True
Function Annotations
Annotate parameters and return types with -> Type:
def greet(name: str) -> str: return f"Hello, {name}!" def add(a: int, b: int) -> int: return a + b def is_adult(age: int) -> bool: return age >= 18
Click "Run" to execute your codeFunctions That Return Nothing
Use None as the return type for functions that don't return a value:
def log_message(message: str) -> None: print(f"[LOG] {message}")
Common Types
| Type | Description | Example |
|---|---|---|
int |
Integers | 42 |
float |
Decimal numbers | 3.14 |
str |
Text | "hello" |
bool |
True/False | True |
bytes |
Byte sequences | b"data" |
None |
No value | None |
Container Types
Since Python 3.9, you can use built-in types directly for generic annotations. For older versions, import from typing.
# Python 3.9+ (recommended) names: list[str] = ["Alice", "Bob"] scores: dict[str, int] = {"Alice": 95, "Bob": 87} coordinates: tuple[float, float] = (10.5, 20.3) unique_ids: set[int] = {1, 2, 3} # Variable-length tuples values: tuple[int, ...] = (1, 2, 3, 4, 5) # Nested containers matrix: list[list[int]] = [[1, 2], [3, 4]] user_data: dict[str, list[str]] = { "Alice": ["alice@example.com", "alice2@example.com"], }
Click "Run" to execute your codeOptional and Union
Optional Values
When a value might be None, use Optional or the modern | None syntax:
from typing import Optional # Traditional syntax def find_user(user_id: int) -> Optional[str]: users = {1: "Alice", 2: "Bob"} return users.get(user_id) # Modern syntax (Python 3.10+) def find_user(user_id: int) -> str | None: users = {1: "Alice", 2: "Bob"} return users.get(user_id)
Union Types
When a value can be one of several types:
from typing import Union # Traditional def process(value: Union[str, int]) -> str: return str(value) # Modern (Python 3.10+) def process(value: str | int) -> str: return str(value)
Click "Run" to execute your codeType Aliases
Create aliases for complex types to keep annotations readable:
# Without alias — hard to read def process_data( records: list[dict[str, list[tuple[int, str]]]], ) -> dict[str, int]: pass # With alias — much clearer Record = dict[str, list[tuple[int, str]]] Summary = dict[str, int] def process_data(records: list[Record]) -> Summary: pass
For formal type aliases (Python 3.12+), use the type statement:
# Python 3.12+ type Vector = list[float] type Matrix = list[Vector] type UserID = int
For earlier versions, use TypeAlias from typing:
from typing import TypeAlias Vector: TypeAlias = list[float] Matrix: TypeAlias = list[Vector]
Generics with TypeVar
When a function works with any type but needs to express relationships between input and output types, use TypeVar:
from typing import TypeVar T = TypeVar("T") def first(items: list[T]) -> T: """Return the first element of a list.""" return items[0] # The return type matches the input type: # first([1, 2, 3]) -> int # first(["a", "b"]) -> str
You can constrain type variables:
from typing import TypeVar # Only allows str or bytes StrOrBytes = TypeVar("StrOrBytes", str, bytes) def concat(a: StrOrBytes, b: StrOrBytes) -> StrOrBytes: return a + b # Bounded type variable — must be a subclass of the bound from typing import TypeVar Numeric = TypeVar("Numeric", bound=float) def double(value: Numeric) -> Numeric: return value * 2
Click "Run" to execute your codeKey Types from the typing Module
Any
Opt out of type checking for a specific value. Use sparingly:
from typing import Any def log(message: Any) -> None: print(str(message))
Callable
Describe function signatures:
from typing import Callable # A function that takes two ints and returns a bool Comparator = Callable[[int, int], bool] def sort_with(items: list[int], key: Comparator) -> list[int]: return sorted(items, key=lambda x: key(x, 0)) # A function with no arguments that returns a string Factory = Callable[[], str]
Literal
Restrict to specific literal values:
from typing import Literal def set_mode(mode: Literal["read", "write", "append"]) -> None: print(f"Mode set to: {mode}") # Only these exact strings are valid: set_mode("read") # OK set_mode("write") # OK # set_mode("delete") # Type error!
TypedDict
Define the shape of dictionaries with specific keys:
from typing import TypedDict class UserProfile(TypedDict): name: str age: int email: str # Must have exactly these keys with these types user: UserProfile = { "name": "Alice", "age": 30, "email": "alice@example.com", }
Click "Run" to execute your codeProtocol Classes: Structural Subtyping
Python's Protocol class (from PEP 544) enables structural subtyping — also known as static duck typing. Instead of requiring inheritance from a specific base class, a Protocol defines what methods and attributes an object must have:
from typing import Protocol class Drawable(Protocol): def draw(self) -> str: ... # No inheritance needed! Just implement the method. class Circle: def draw(self) -> str: return "Drawing a circle" class Square: def draw(self) -> str: return "Drawing a square" def render(shape: Drawable) -> None: print(shape.draw()) # Both work because they have a draw() method render(Circle()) render(Square())
Protocol vs Inheritance-Based Polymorphism
This is a fundamental distinction in Python typing:
Inheritance-based (nominal subtyping):
from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: pass class Circle(Shape): # Must explicitly inherit def area(self) -> float: return 3.14 * self.radius ** 2
Protocol-based (structural subtyping):
from typing import Protocol class HasArea(Protocol): def area(self) -> float: ... class Circle: # No inheritance required def area(self) -> float: return 3.14 * self.radius ** 2
The key difference: with inheritance, the class must know about the base class and explicitly inherit from it. With protocols, any class that has the right methods and attributes satisfies the protocol automatically, even third-party classes you can't modify.
| Approach | Mechanism | Flexibility | Explicitness |
|---|---|---|---|
| ABC/Inheritance | Nominal (must inherit) | Less flexible | Very explicit |
| Protocol | Structural (duck typing) | More flexible | Implicit |
When to use which: Use ABCs when you want to enforce a contract and provide shared implementation. Use Protocols when you want to accept any object with the right interface, especially useful for type-checking code that uses duck typing.
Click "Run" to execute your codeWhen to Use Type Hints
Type hints are most valuable in certain contexts:
Always Use Them For:
- Public APIs — library functions, class interfaces, module exports
- Complex functions — where parameter types aren't obvious
- Team projects — helps everyone understand the codebase
- Long-lived code — you'll forget what types things are
Optional For:
- Simple scripts — a 20-line script may not benefit
- Obvious types —
name = "Alice"doesn't needname: str = "Alice" - Internal helpers — small private functions used in one place
- Prototyping — add types after the design stabilizes
Gradual Adoption
You don't have to annotate everything at once. Start with:
- Public function signatures
- Complex function signatures
- Class attributes
- Variables with non-obvious types
# Start here: annotate public functions def calculate_discount(price: float, percentage: float) -> float: return price * (1 - percentage / 100) # Then add class annotations class Order: items: list[str] total: float def __init__(self, items: list[str]) -> None: self.items = items self.total = 0.0
Static Type Checkers
Type hints are only documentation unless you use a tool to verify them.
mypy
The original and most widely used type checker:
# Install pip install mypy # Check a file mypy my_module.py # Check a project mypy src/ # Common flags mypy --strict src/ # Maximum strictness mypy --ignore-missing-imports src/ # Skip untyped third-party libs
pyright
Microsoft's type checker, also powers Pylance in VS Code. Faster than mypy and often catches more issues:
# Install pip install pyright # Check a project pyright src/
Configuration
Both tools can be configured in pyproject.toml:
# mypy configuration [tool.mypy] python_version = "3.12" strict = true warn_return_any = true # pyright configuration [tool.pyright] pythonVersion = "3.12" typeCheckingMode = "strict"
PEP 484 and PEP 526
The key PEPs that define Python's type hinting system:
- PEP 484 (2014) — introduced type hints for function annotations
- PEP 526 (2016) — added variable annotations (
x: int = 5) - PEP 544 (2017) — introduced Protocol classes
- PEP 604 (2020) — the
X | Yunion syntax - PEP 612 (2020) — ParamSpec for decorator typing
- PEP 695 (2023) — the
typestatement and cleaner generic syntax
The type system continues to evolve with each Python release, consistently making annotations more expressive and easier to write.
Click "Run" to execute your code