Python’s dynamic typing offers flexibility, but it can lead to runtime errors that could have been caught earlier. Type hints, introduced in Python 3.5, allow you to annotate your code with type information. Combined with static analysis tools like mypy, you can catch type-related bugs before your code runs.
This tutorial walks you through adding type hints to your Python projects and using mypy to verify type correctness. Whether you’re working on a small script or a large codebase, these techniques will help you write more reliable code.
Prerequisites
Before starting, you need Python 3.8 or later installed. Install mypy using pip:
pip install mypy
To verify the installation:
mypy --version
You should see output like mypy 1.8.0 or similar. Now you’re ready to add type hints to your code.
Step 1: Basic Type Annotations
Type hints use a colon after parameter names and -> before the return type. Here’s a simple example:
def greet(name: str) -> str:
return f"Hello, {name}!"
result = greet("Alice")
print(result)
This function accepts a string parameter and returns a string. Run mypy on this file:
mypy example.py
If there are no type errors, mypy reports success. Now try calling the function with the wrong type:
def greet(name: str) -> str:
return f"Hello, {name}!"
result = greet(42) # Wrong type
Running mypy now shows an error:
example.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Type hints help you catch these mistakes during development instead of at runtime.
For basic types, use these annotations:
age: int = 25
price: float = 19.99
is_active: bool = True
message: str = "Hello"
data: bytes = b"binary"
nothing: None = None
You can also annotate function parameters without default values:
def calculate_total(price: float, quantity: int) -> float:
return price * quantity
total = calculate_total(9.99, 3)
Step 2: Complex Types and Generics
For collections like lists and dictionaries, use types from the typing module (or built-in generics in Python 3.9+):
from typing import List, Dict, Tuple, Set, Optional
# Python 3.9+ can use lowercase
def process_numbers(numbers: list[int]) -> int:
return sum(numbers)
def get_user_data() -> dict[str, str]:
return {"name": "Alice", "email": "alice@example.com"}
def parse_coordinates(data: str) -> tuple[float, float]:
x, y = data.split(",")
return (float(x), float(y))
def unique_tags(tags: list[str]) -> set[str]:
return set(tags)
The Optional type indicates a value can be None:
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)
result = find_user(3) # Returns None
if result is not None:
print(f"Found user: {result}")
For functions that accept multiple types, use Union:
from typing import Union
def format_id(id_value: Union[int, str]) -> str:
return str(id_value).zfill(6)
print(format_id(42)) # "000042"
print(format_id("100")) # "000100"
Python 3.10+ supports the | operator as a cleaner alternative:
def format_id(id_value: int | str) -> str:
return str(id_value).zfill(6)
For callable objects (functions), use the Callable type:
from typing import Callable
def apply_operation(x: int, y: int, operation: Callable[[int, int], int]) -> int:
return operation(x, y)
def add(a: int, b: int) -> int:
return a + b
result = apply_operation(5, 3, add) # Returns 8
Step 3: Type Hints for Classes and Protocols
Classes can have type-annotated attributes and methods:
class User:
def __init__(self, name: str, age: int) -> None:
self.name: str = name
self.age: int = age
self.email: Optional[str] = None
def set_email(self, email: str) -> None:
self.email = email
def get_display_name(self) -> str:
return f"{self.name} ({self.age})"
user = User("Alice", 30)
user.set_email("alice@example.com")
For generic classes, use TypeVar:
from typing import TypeVar, Generic
T = TypeVar('T')
class Box(Generic[T]):
def __init__(self, content: T) -> None:
self.content = content
def get_content(self) -> T:
return self.content
int_box = Box[int](42)
str_box = Box[str]("hello")
number = int_box.get_content() # mypy knows this is int
text = str_box.get_content() # mypy knows this is str
Protocols define structural typing (similar to interfaces):
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"
def render_shape(shape: Drawable) -> None:
print(shape.draw())
render_shape(Circle())
render_shape(Square())
Any class with a draw method that returns a string satisfies the Drawable protocol. You don’t need explicit inheritance.
For class methods and static methods:
class MathUtils:
@staticmethod
def add(x: int, y: int) -> int:
return x + y
@classmethod
def from_string(cls, data: str) -> "MathUtils":
return cls()
Step 4: Configuring mypy for Your Project
Create a mypy.ini file in your project root to customize mypy’s behavior:
[mypy]
python_version = 3.10
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
Key configuration options:
python_version: Target Python versionwarn_return_any: Warn when returningAnytypedisallow_untyped_defs: Require type annotations on all functionsdisallow_any_unimported: PreventAnyfrom untyped importsno_implicit_optional: Disallow implicitOptionaltypesstrict_equality: Warn on comparing incompatible types
For strict checking, enable strict mode:
[mypy]
strict = True
This enables multiple strict options at once. You can exclude specific directories:
[mypy]
strict = True
[mypy-tests.*]
disallow_untyped_defs = False
For third-party packages without type stubs, you can suppress errors:
[mypy-some_package.*]
ignore_missing_imports = True
Run mypy on your entire project:
mypy src/
Or on specific files:
mypy src/main.py src/utils.py
You can also configure mypy in pyproject.toml:
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
Step 5: Gradual Typing Strategy
You don’t need to add type hints to your entire codebase at once. Start with new code and high-risk areas:
- New code: Add type hints as you write new functions and classes
- Public APIs: Type your public interfaces first
- Bug-prone areas: Add types where you’ve had runtime errors
- Core logic: Focus on business-critical code
Use # type: ignore comments to suppress specific errors temporarily:
result = some_untyped_function() # type: ignore
Add a reason to make it clear:
result = legacy_function() # type: ignore[no-untyped-call]
For functions you can’t type yet, use Any:
from typing import Any
def legacy_processor(data: Any) -> Any:
# Complex logic without types
return process(data)
When working with untyped third-party libraries, install type stubs if available:
pip install types-requests
pip install types-PyYAML
Create your own stub files for libraries without stubs. For a package mylib, create mylib.pyi:
def some_function(x: int) -> str: ...
class SomeClass:
def method(self) -> None: ...
Run mypy incrementally as you add types:
mypy --incremental src/
This caches results and only checks changed files, making type checking faster on large projects.
Common Pitfalls
Mutable default arguments: Don’t use mutable defaults with type hints:
# Wrong
def append_to_list(item: int, items: list[int] = []) -> list[int]:
items.append(item)
return items
# Correct
def append_to_list(item: int, items: list[int] | None = None) -> list[int]:
if items is None:
items = []
items.append(item)
return items
Forgetting Optional: If a parameter can be None, use Optional:
# Wrong
def greet(name: str) -> str:
if name is None:
return "Hello, stranger!"
return f"Hello, {name}!"
# Correct
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger!"
return f"Hello, {name}!"
Using Any too much: Overusing Any defeats the purpose of type hints. Be specific when possible:
# Weak
def process(data: Any) -> Any:
return data.upper()
# Better
def process(data: str) -> str:
return data.upper()
Circular imports: Type hints can cause circular import issues. Use string literals for forward references:
# models.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .other import OtherClass
class MyClass:
def method(self, other: "OtherClass") -> None:
pass
Or use from __future__ import annotations (Python 3.7+):
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next
Ignoring compatibility: Type hints don’t affect runtime. Don’t rely on them for validation:
def divide(a: int, b: int) -> float:
return a / b
# This runs fine at runtime, even though type is wrong
result = divide("10", "2") # Runtime error later
Use runtime validation for user input:
def divide(a: int, b: int) -> float:
if not isinstance(a, int) or not isinstance(b, int):
raise TypeError("Both arguments must be integers")
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Summary
Type hints and mypy help you catch bugs earlier and make your code easier to understand. Start by adding types to new code, then gradually expand coverage. Use mypy’s configuration options to match your project’s needs.
Key takeaways:
- Basic types (int, str, bool) catch common mistakes
- Generic types (list, dict) specify container contents
- Optional and Union handle values that can be multiple types
- Protocols enable structural typing without inheritance
- Configuration files customize mypy’s strictness
- Gradual typing lets you adopt types incrementally
The time you invest in adding type hints pays off through fewer bugs, better documentation, and easier refactoring. When something breaks six months from now, the type annotations will point you straight to the problem.
Discussion
Leave a comment
No comments yet
Be the first to start the conversation.