From dynamic chaos to static certainty: mastering MyPy and strict mode
The "I Forgot What This Returns" Problem
You're six months into a project. You need to call a function someone else wrote:
def process(data):
# ... 50 lines of code ...
return result
What does data need to be? A list? A dict? A pandas DataFrame? A custom class?
What does result actually contain? A boolean? A tuple? None?
You have three options:
- Read all 50 lines of implementation code
- Find example usage somewhere else in the codebase
- Run it and hope for the best
All three are terrible.
The Solution
def process(data: pd.DataFrame) -> tuple[bool, str]:
# ... 50 lines of code ...
return result
Now you know instantly:
- Input: pandas DataFrame
- Output: tuple of (bool, str)
No runtime cost. No reading code.
The Misconception
Many developers think type hints are for Python—that they change how the code runs. They don't.
def add(a: int, b: int) -> int:
return a + b
print(add("hello", "world")) # "helloworld" - Python doesn't care!
Python completely ignores type hints at runtime. They're not constraints; they're documentation with a superpower: they can be automatically verified.
Type hints are for:
- Your IDE - Autocomplete and error detection
- MyPy - Static type checker that finds bugs before runtime
- Other developers - Understanding your code without reading it
- Future you - Remembering what you meant six months ago
The Modern Syntax (Python 3.10+)
If you learned Python type hints before 2021, forget what you know. The syntax changed dramatically in Python 3.9 and 3.10.
The Old Way (Verbose and Ugly)
from typing import List, Dict, Union, Optional, Tuple
def get_user(user_id: Union[int, None]) -> Dict[str, Union[str, int]]:
pass
def process_items(items: Optional[List[str]]) -> Tuple[bool, str]:
pass
This required importing half of the typing module and using capital letters for built-in types (List instead of list).
The New Way (Clean and Native)
def get_user(user_id: int | None) -> dict[str, str | int]:
pass
def process_items(items: list[str] | None) -> tuple[bool, str]:
pass
What changed:
-
Union syntax:
Union[int, None]→int | None -
Optional is dead:
Optional[str]→str | None -
Lowercase collections:
List[int]→list[int],Dict[str, int]→dict[str, int] -
Tuple syntax:
Tuple[bool, str]→tuple[bool, str]
The Compatibility Timeline
-
Python 3.9: Can use
list[int],dict[str, int]but not|for unions -
Python 3.10+: Can use
|for unions, makingUnionandOptionalobsolete - Python 3.12+: New generic syntax for classes (we'll cover this)
If you're on Python 3.10+, never import Union or Optional again.
Generic Collections: The Cheat Sheet
# Lists - homogeneous collection
numbers: list[int] = [1, 2, 3]
names: list[str] = ["Alice", "Bob"]
mixed: list[int | str] = [1, "two", 3]
# Dictionaries - key and value types
scores: dict[str, int] = {"Alice": 100, "Bob": 95}
config: dict[str, str | int | bool] = {"debug": True, "port": 8080}
# Tuples - fixed length and types
point: tuple[float, float] = (1.0, 2.0)
user: tuple[int, str, bool] = (1, "Alice", True)
# Tuples - variable length, same type
coordinates: tuple[float, ...] = (1.0, 2.0, 3.0, 4.0) # Any number of floats
# Sets
unique_ids: set[int] = {1, 2, 3}
The ellipsis (...) in tuple types means "zero or more elements of this type":
-
tuple[int, int]- exactly 2 integers -
tuple[int, ...]- any number of integers
The "Chicken and Egg" Problem: Forward References
Try to write a method that returns the same type as the class:
class Money:
def __init__(self, amount: float, currency: str):
self.amount = amount
self.currency = currency
def __add__(self, other: Money) -> Money: # NameError!
return Money(self.amount + other.amount, self.currency)
Boom. NameError: name 'Money' is not defined.
Why? Because when Python is executing the class body, the class doesn't exist yet. It's being defined. So other: Money tries to reference a name that doesn't exist.
The Old Fix (Quotes)
The traditional workaround was to use strings:
class Money:
def __add__(self, other: "Money") -> "Money": # Works!
return Money(self.amount + other.amount, self.currency)
Strings aren't evaluated at import time they're just stored. MyPy evaluates them later when the class exists.
But this is ugly and error-prone (typos don't cause errors until type checking).
The New Fix (Future Annotations)
Add one line at the top of your file:
from __future__ import annotations
class Money:
def __init__(self, amount: float, currency: str):
self.amount = amount
self.currency = currency
def __add__(self, other: Money) -> Money: # Works!
return Money(self.amount + other.amount, self.currency)
This directive tells Python: "Treat all type annotations as strings automatically."
Under the hood, Python stores Money as the string "Money", but you write it without quotes. MyPy evaluates it later when type checking.
Why This Matters
Without this, you'd need quotes everywhere:
class TreeNode:
def __init__(self, value: int):
self.value = value
self.left: "TreeNode | None" = None
self.right: "TreeNode | None" = None
def add_child(self, node: "TreeNode") -> None:
pass
With from __future__ import annotations:
from __future__ import annotations
class TreeNode:
def __init__(self, value: int):
self.value = value
self.left: TreeNode | None = None
self.right: TreeNode | None = None
def add_child(self, node: TreeNode) -> None:
pass
Always use this import in every file with type hints. It will become the default behavior in Python 4.0.
The Any vs object Distinction
When you want to accept "anything," you have two options with very different meanings.
object - The Safe Universal Type
def print_anything(value: object) -> None:
print(value)
print_anything(42) # OK
print_anything("hello") # OK
print_anything([1, 2, 3]) # OK
When you type something as object, you're saying:
- "I accept any type"
- "But I can only do things that work on ANY object"
What can you do with an object?
def process(value: object) -> None:
print(value) # OK - all objects can be printed
str(value) # OK - all objects can be converted to string
value == 5 # OK - all objects support equality
# These are errors:
value + 5 # Error! Not all objects support addition
value.some_method() # Error! Not all objects have this method
value[0] # Error! Not all objects are indexable
MyPy knows that object only supports the universal protocol: __str__, __repr__, __eq__, etc.
Any - The "Turn Off Type Checking" Escape Hatch
from typing import Any
def process(value: Any) -> None:
print(value) # OK
value + 5 # OK - MyPy assumes you know what you're doing
value.some_method() # OK - MyPy trusts you
value[0] # OK - MyPy allows everything
Any tells MyPy: "Don't check this variable. I know what I'm doing."
It's dangerous because it disables type safety:
def broken(x: Any) -> int:
return x + 5 # MyPy says this is fine
broken("hello") # Runtime error: can't add string and int
When to Use Each
Use object when:
- You truly accept any type
- You only use universal operations
- Example:
__eq__methods that compare with anything
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount
Use Any when:
- You're working with truly dynamic data (JSON from API)
- You're gradually typing legacy code
- You're interfacing with untyped libraries
- You've exhausted other options
import json
def load_config(path: str) -> Any: # JSON can be anything
with open(path) as f:
return json.load(f)
The Gradual Typing Strategy
When retrofitting types to legacy code:
# Stage 1: Everything is Any
def process_data(data: Any) -> Any:
result = transform(data)
return result
# Stage 2: Narrow the outputs
def process_data(data: Any) -> dict[str, int]:
result = transform(data)
return result
# Stage 3: Narrow the inputs
def process_data(data: list[str]) -> dict[str, int]:
result = transform(data)
return result
# Stage 4: No more Any!
Start with Any everywhere, then gradually replace it with specific types as you understand the code.
MyPy Configuration
Type hints are useless unless you enforce them. That's where MyPy comes in, a static type checker that analyzes your code without running it.
Installing MyPy
pip install mypy
Basic Usage
# Check a single file
mypy my_script.py
# Check a directory
mypy src/
# Check and show error codes
mypy --show-error-codes .
The Configuration File
Create a pyproject.toml file (or mypy.ini) in your project root:
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
# Strict mode - the final boss
strict = true
Let's break down what these do:
disallow_untyped_defs = true - Every function MUST have type hints:
# Error: Function is missing a type annotation
def process(data):
return data.upper()
# OK
def process(data: str) -> str:
return data.upper()
warn_return_any = true - Functions can't return Any:
import json
# Error: Returning Any from function declared to return int
def load_number() -> int:
return json.load(open("config.json"))["number"] # json.load returns Any
# OK
def load_number() -> int:
value: Any = json.load(open("config.json"))["number"]
return int(value) # Explicit conversion
strict = true - Enables ALL strict checks:
# Error: Need to handle None case
def get_first(items: list[str]) -> str:
return items[0] # What if items is empty?
# OK
def get_first(items: list[str]) -> str | None:
return items[0] if items else None
The NotImplemented Problem
Remember that arithmetic operations should return NotImplemented, not raise exceptions? MyPy doesn't like this:
def __add__(self, other: Money) -> Money:
if isinstance(other, Money):
return Money(self.amount + other.amount, self.currency)
return NotImplemented # Error: Incompatible return type!
MyPy says: "You declared this returns Money, but you're returning NotImplemented!"
The fix is to be explicit about both return types:
from typing import NotImplementedType
def __add__(self, other: object) -> Money | NotImplementedType:
if isinstance(other, Money):
return Money(self.amount + other.amount, self.currency)
return NotImplemented
Note we also changed other: Money to other: object—we accept any type and check it at runtime.
Handling Third-Party Libraries
Many packages don't have type stubs. MyPy will complain:
error: Skipping analyzing "requests": module is installed, but missing library stubs
Solutions:
[tool.mypy]
# Option 1: Ignore missing imports globally
ignore_missing_imports = true
# Option 2: Configure per-package
[[tool.mypy.overrides]]
module = "requests.*"
ignore_missing_imports = true
# Option 3: Install type stubs
# pip install types-requests
Top comments (0)