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