Introduction
Python is a dynamically typed language, but since Python 3.5, type hints allow you to add static type information to your code. Type hints do not affect runtime behavior, but they provide several benefits:
- Improved readability: Function argument and return types are immediately clear
- Better IDE support: More accurate auto-completion and refactoring
- Early bug detection: Static type checkers like mypy catch errors before execution
- Self-documenting code: Types serve as inline documentation
This article covers type hints from basics to static type checking with mypy.
Basic Type Hints
Variable Type Hints
Python 3.6+ supports type hints on variables.
name: str = "hello"
age: int = 30
height: float = 175.5
is_active: bool = True
Assigning a value that doesn’t match the type hint won’t cause a runtime error, but mypy will flag it.
name: str = 123 # mypy: Incompatible types in assignment
Function Type Hints
The most common use is annotating function arguments and return values.
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
Functions That Return Nothing
Use -> None for functions without a return value.
def log_message(message: str) -> None:
print(f"[LOG] {message}")
Default Arguments
When using default arguments, place the type hint before the =.
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}"
Collection Types
Python 3.9+ Built-in Generics
From Python 3.9 onward, built-in types can be used directly as generics.
# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (35.68, 139.76)
unique_tags: set[str] = {"python", "typing"}
Use ... for variable-length tuples.
# A tuple of arbitrary length containing ints
numbers: tuple[int, ...] = (1, 2, 3, 4, 5)
Python 3.8 and Earlier (typing Module)
Before Python 3.9, you need to import from the typing module.
from typing import List, Dict, Tuple, Set
names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}
coordinates: Tuple[float, float] = (35.68, 139.76)
unique_tags: Set[str] = {"python", "typing"}
Recommendation: On Python 3.9+, prefer built-in types (
list,dict,tuple,set). Thetyping.Listforms are being deprecated.
Nested Collections
Collection types can be nested.
# Dict with string keys and list of strings as values
user_tags: dict[str, list[str]] = {
"alice": ["python", "ml"],
"bob": ["go", "docker"],
}
# List of tuples
points: list[tuple[int, int]] = [(0, 0), (1, 2), (3, 4)]
Optional and Union
Optional
Use when a value can be None.
from typing import Optional
# Python 3.9 and earlier
def find_user(user_id: int) -> Optional[str]:
if user_id == 1:
return "Alice"
return None
Python 3.10+ supports the | operator for a more concise syntax.
# Python 3.10+
def find_user(user_id: int) -> str | None:
if user_id == 1:
return "Alice"
return None
Union
Use when a value can be one of multiple types.
from typing import Union
# Python 3.9 and earlier
def process(value: Union[int, str]) -> str:
return str(value)
# Python 3.10+
def process(value: int | str) -> str:
return str(value)
Optional Is Syntactic Sugar for Union
Optional[X] is exactly equivalent to Union[X, None].
# All of these are equivalent
from typing import Optional, Union
x: Optional[str]
x: Union[str, None]
x: str | None # Python 3.10+
TypedDict and dataclass
TypedDict
Define the types of dictionary keys and values. Useful for API responses and configuration.
from typing import TypedDict
class UserProfile(TypedDict):
name: str
age: int
email: str
is_active: bool
def get_user() -> UserProfile:
return {
"name": "Alice",
"age": 30,
"email": "alice@example.com",
"is_active": True,
}
user = get_user()
print(user["name"]) # "Alice" — IDE provides auto-completion
For optional keys, use total=False or per-key NotRequired (3.11+).
from typing import TypedDict, NotRequired # Python 3.11+
class UserProfile(TypedDict):
name: str
age: int
nickname: NotRequired[str] # Optional key
dataclass
For structured data, dataclass is often a better fit. Unlike TypedDict, it supports attribute access (.name).
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
email: str
is_active: bool = True
user = User(name="Alice", age=30, email="alice@example.com")
print(user.name) # "Alice"
print(user) # User(name='Alice', age=30, email='alice@example.com', is_active=True)
Use frozen=True for immutability.
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # FrozenInstanceError
TypedDict vs dataclass
| Aspect | TypedDict | dataclass |
|---|---|---|
| Data structure | dict | Class instance |
| Access | d["key"] | obj.attr |
| JSON compat | Directly usable as dict | Conversion needed |
| Immutability | Not supported | frozen=True |
| Use case | API responses, config | Domain models, value objects |
Generics
Generic Functions with TypeVar
Abstract over types to create reusable functions.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
# Type inference works
name = first(["Alice", "Bob"]) # str
number = first([1, 2, 3]) # int
You can constrain a TypeVar.
from typing import TypeVar
Number = TypeVar("Number", int, float)
def double(x: Number) -> Number:
return x * 2
double(5) # OK: int
double(3.14) # OK: float
double("hi") # mypy error
Python 3.12+ Syntax
Python 3.12 introduced a new syntax that eliminates the need to explicitly define TypeVar.
# Python 3.12+
def first[T](items: list[T]) -> T:
return items[0]
Generic Classes
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:
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def is_empty(self) -> bool:
return len(self._items) == 0
# Type is determined at usage
int_stack = Stack[int]()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop() # 2
str_stack = Stack[str]()
str_stack.push("hello")
In Python 3.12+, classes also support the new syntax.
# Python 3.12+
class Stack[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()
Protocol (Structural Subtyping)
Type-Safe Duck Typing
Protocol (Python 3.8+) brings type safety to Python’s duck typing philosophy.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
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())
# Circle and Square don't explicitly inherit Drawable,
# but they pass type checking because they have a draw() method
render(Circle()) # OK
render(Square()) # OK
Protocol vs ABC
Protocol uses structural subtyping and does not require explicit inheritance.
from abc import ABC, abstractmethod
from typing import Protocol
# ABC: Explicit inheritance required (nominal subtyping)
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> str:
...
class CircleABC(DrawableABC): # Must inherit
def draw(self) -> str:
return "circle"
# Protocol: No inheritance needed (structural subtyping)
class DrawableProtocol(Protocol):
def draw(self) -> str:
...
class CircleProtocol: # No inheritance. Just having draw() is enough
def draw(self) -> str:
return "circle"
Practical Example: Logger Interface
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None:
...
class ConsoleLogger:
def log(self, message: str) -> None:
print(f"[CONSOLE] {message}")
class FileLogger:
def __init__(self, path: str) -> None:
self.path = path
def log(self, message: str) -> None:
with open(self.path, "a") as f:
f.write(f"{message}\n")
def process_data(data: list[int], logger: Logger) -> int:
logger.log(f"Processing {len(data)} items")
result = sum(data)
logger.log(f"Result: {result}")
return result
# Both satisfy the Logger Protocol
process_data([1, 2, 3], ConsoleLogger())
process_data([1, 2, 3], FileLogger("/tmp/app.log"))
Static Type Checking with mypy
Installation and Basic Usage
pip install mypy
# Check a single file
mypy script.py
# Check an entire directory
mypy src/
# Check a package
mypy -p mypackage
Errors mypy Detects
# example.py
def greet(name: str) -> str:
return f"Hello, {name}"
result: int = greet("Alice") # error: Incompatible types in assignment
greet(123) # error: Argument 1 has incompatible type "int"
$ mypy example.py
example.py:4: error: Incompatible types in assignment
(expression has type "str", variable has type "int")
example.py:5: error: Argument 1 to "greet" has incompatible type "int";
expected "str"
Found 2 errors in 1 file (checked 1 source file)
Configuration
Manage settings in pyproject.toml or mypy.ini.
# pyproject.toml
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
# For third-party libraries without type stubs
[[tool.mypy.overrides]]
module = ["some_untyped_library.*"]
ignore_missing_imports = true
–strict Mode
--strict enables the most rigorous checks. Recommended for new projects.
mypy --strict src/
Key options enabled by --strict:
| Option | Description |
|---|---|
disallow_untyped_defs | Forbid functions without type hints |
disallow_any_generics | Require list[int] instead of bare list |
warn_return_any | Warn on functions returning Any |
no_implicit_reexport | Forbid implicit re-exports |
strict_equality | Warn on == between incompatible types |
Common Errors and Fixes
# 1. Missing return statement
def get_name(user_id: int) -> str:
if user_id == 1:
return "Alice"
# error: Missing return statement
# Fix: return "" or raise ValueError()
# 2. Incompatible types in assignment
data: list[int] = []
data.append("hello") # error
# Fix: data: list[int | str] = [] or data.append(123)
# 3. Item "None" of "Optional[str]" has no attribute "upper"
def process(name: str | None) -> str:
return name.upper() # error: name could be None
# Fix: Add a None check
# if name is None:
# return ""
# return name.upper()
# 4. type: ignore for specific lines (last resort)
result = some_untyped_function() # type: ignore[no-untyped-call]
Type Hints Best Practices
| Practice | Description |
|---|---|
| Start with public APIs | Begin by annotating function signatures (arguments and return types) |
Avoid Any | Any disables type checking. Use specific types or object instead |
Be explicit about Optional | Always use Optional / X | None when None is a possible return value |
Constrain TypeVar | Prefer bound or constrained types over unconstrained TypeVar |
| Adopt incrementally | Apply --disallow-untyped-defs gradually to existing projects |
Comment type: ignore | Add reasons: # type: ignore[arg-type] # legacy API |
| Integrate mypy in CI | Run mypy in pre-commit hooks or CI pipelines |
Include py.typed marker | Add a py.typed file when publishing typed libraries |
Related Articles
- Python Decorators: Mechanics and Practical Patterns - Practical patterns for adding type hints to decorators.
- Introduction to Python asyncio - Type hints for async functions (
Coroutine,Awaitable). - Python Regular Expressions Practical Guide - Using type hints with
remodule (re.Match,re.Pattern). - Python logging Module Practical Guide - Related to Protocol pattern for logger interface design.
References
- Python Documentation: typing
- mypy Documentation
- PEP 484 – Type Hints
- PEP 604 – Allow writing union types as X | Y
- PEP 695 – Type Parameter Syntax
- PEP 544 – Protocols: Structural subtyping
Related Tools
- DevToolBox - Free Developer Tools - 85+ developer tools including JSON formatter, regex tester, and more
- CalcBox - Everyday Calculation Tools - 61+ calculation tools including statistics, frequency conversion, and more