Type Hints and Static Typing with mypy
Adding types to Python without losing what makes it Python — annotations, generics, and catching bugs before runtime.
Python Is Still Fully Dynamically Typed
Type hints are exactly what the name says — hints. Python's interpreter reads them, stores them, and then does nothing further with them at runtime. Adding type hints to a function does not make Python check argument types when it is called, and does not change how the code executes in any way.
def add(a: int, b: int) -> int:
return a + b
print(add(3, 4)) # 7 — works, as expected
print(add("3", "4")) # "34" — ALSO runs fine! Python never checked the types at allmypy, covered in this module) as a development-time step, completely separate from actually running the program.Annotating Variables, Parameters, and Return Values
name: str = "Asha"
age: int = 30
price: float = 19.99
is_active: bool = True
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
def log_event(message: str) -> None: # -> None means "returns nothing meaningful"
print(message)The value these annotations provide is entirely about tooling and readability, not runtime behaviour: your editor can now warn you immediately if you pass the wrong type, autocomplete becomes far more accurate (since the editor knows exactly what a variable's type is), and anyone reading the function signature knows what it expects without reading the implementation.
Expressing "Could Also Be None" or "One of Several Types"
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
# Optional[str] means "either a str, or None"
if user_id in database:
return database[user_id]
return Nonefrom typing import Union
def parse_id(raw: Union[str, int]) -> int:
return int(raw)def find_user(user_id: int) -> str | None: # same meaning as Optional[str]
...
def parse_id(raw: str | int) -> int: # same meaning as Union[str, int]
return int(raw)The | syntax (added in Python 3.10) is now the preferred, more concise way to write these — Optional and Union from the typing module still work identically and remain common in codebases that need to support older Python versions, but new code targeting 3.10+ generally uses | directly.
Annotating What's Inside a Collection
def get_names(users: list[dict[str, str]]) -> list[str]:
return [u["name"] for u in users]
def word_counts(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
def unique_ids(ids: list[int]) -> set[int]:
return set(ids)Modern Python (3.9+) lets you use the built-in collection types directly as generics — list[int], dict[str, int] — rather than the older, more verbose typing.List[int]/typing.Dict[str, int] forms required on earlier versions. The built-in forms are now the standard, idiomatic choice for any project not specifically constrained to pre-3.9 Python.
Typing Without Requiring Inheritance
The Abstract Base Classes module covered enforcing a contract through explicit inheritance. typing.Protocol offers a different approach — describing a required shape (which methods/attributes must exist) without requiring any class to explicitly inherit from it at all. Any object satisfies a Protocol just by having the right methods, structurally — much closer to duck typing, but checkable ahead of time by a type checker.
from typing import Protocol
class Quacks(Protocol):
def quack(self) -> str:
...
class RealDuck:
def quack(self) -> str:
return "Quack!"
class ToyDuck:
def quack(self) -> str:
return "Squeak-quack"
def make_it_quack(duck: Quacks) -> str:
return duck.quack()
make_it_quack(RealDuck()) # type-checks fine — RealDuck was never declared to inherit from Quacks
make_it_quack(ToyDuck()) # ALSO type-checks fine — it just needs a matching quack() methodThis is genuinely valuable when you want the safety of static type checking on code that is intentionally written in Python's duck-typed style — libraries you do not control (and cannot add inheritance to) can still satisfy a Protocol, as long as they happen to have the right method shape.
Actually Checking the Types You've Annotated
pip install mypy
mypy your_script.pydef calculate_total(prices: list[float]) -> float:
return sum(prices)
calculate_total(["19.99", "29.99"]) # a list of STRINGS, not floats
# Running mypy on this file reports, without ever executing the code:
# error: Argument 1 to "calculate_total" has incompatible type "list[str]";
# expected "list[float]"This is the entire point of static typing in Python: the bug above — passing a list of strings where floats were expected — would not raise any error at runtime here (Python would happily sum() a list of numeric-looking strings incorrectly, or crash somewhere downstream depending on what happens next), but mypy catches it before the program is ever run, typically wired into CI so a type error blocks a pull request the same way a failing test would.
Adopting Type Hints on an Existing, Untyped Codebase
Almost no real codebase gets type-annotated all at once — Python's typing system is designed to be adopted gradually, and mypy fully supports a codebase that is only partially annotated.
def legacy_function(x, y): # no annotations at all
return x + y # mypy does not check this function's internals by default
def new_function(x: int, y: int) -> int: # fully annotated
return x + y # mypy DOES check this oneA pragmatic, genuinely common real-world strategy: start by annotating new code and any function currently being touched for other reasons (a natural side effect of a normal PR, not a dedicated typing effort), enable mypy in CI in a lenient/permissive mode so it does not block on the large amount of still-untyped legacy code, then gradually tighten mypy's strictness settings (module by module, or file by file) as coverage grows over time — rather than attempting a single enormous, risky PR that annotates the entire codebase at once.
Catching a Payments Bug in CI, Not in Production, at a Nashville Fintech Company
An engineer refactors a function's return type from returning cents as an int to returning dollars as a float, correctly updating the type hint. A different call site elsewhere in the payments module, untouched by this PR, still treats the return value as integer cents and multiplies it directly into a database write.
def get_transaction_amount(txn_id: str) -> float: # changed from -> int
...
return amount_in_dollars
# Elsewhere, in a completely different file, untouched by this PR:
def record_fee(txn_id: str) -> None:
amount = get_transaction_amount(txn_id)
fee_cents = amount * 100 # was correct when amount was already cents; now double-convertsWhat actually happened
mypy, running in CI on every pull request, immediately flagged that record_fee's usage was inconsistent with the type system's understanding of the surrounding code once the annotation propagated — specifically catching that a value now documented as dollars was still being treated with cents-oriented logic elsewhere. The PR was blocked in review, not caught by a customer noticing an incorrect fee days later. The team's own retrospective note: "a type checker doesn't understand business logic, but it absolutely understands when two pieces of code disagree about what a value represents — and that disagreement is exactly what caused this bug."
Four Misconceptions About Type Hints
5 Interview Questions — With Complete Answers
Type Hint Mistakes Beginners Make Constantly
Errors You Will Hit With Type Hints & mypy — And Exactly Why
🎯 Key Takeaways
- ✓Type hints are ignored by the Python interpreter at runtime — they change nothing about how code actually executes.
- ✓Real enforcement comes from a separate static type checker, mypy being the standard choice, typically run in CI to catch mismatches before code is ever executed.
- ✓str | None (3.10+) and Optional[str] mean the same thing; list[int] (3.9+) and typing.List[int] mean the same thing — the built-in/pipe forms are the modern preferred style.
- ✓typing.Protocol enables structural typing — satisfying a contract by shape alone, with no inheritance required, unlike an abstract base class.
- ✓mypy supports gradual adoption — unannotated code is simply not checked, so a codebase can be typed incrementally rather than all at once.
- ✓A type checker catches inconsistencies BETWEEN pieces of code about what a value represents — exactly the class of bug shown in the Real World example, caught in CI instead of production.
What comes next
Module 37 covers working with real-world APIs in Python — the requests library, authentication, and the timeout mistake that causes production incidents.
Module 37 → Working with APIs in PythonDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.