What you'll learn
Type hints let you annotate what your variables, parameters, and return values are supposed to be. Python doesn't enforce them at runtime — but your editor and a tool called mypy use them to catch whole categories of bugs before you ever run the code, and to give you sharper autocomplete and documentation.
By the end of this lesson you'll be able to:
- Annotate variables, function parameters, and return types
- Describe collections and "maybe-missing" values with
list[int]andX | None - Write reusable generic code with
TypeVarand structural types withProtocol - Model exact shapes and values with
TypedDict,Literal, andFinal - Run
mypyto find type bugs statically
Why type hints
A type hint is a promise about a value, written right in the code. The crucial thing to understand first: CPython does not check them at runtime. They're stored on the object for tools to read, then otherwise ignored.
def double(n: int) -> int:
return n * 2
print(double(10)) # 20 — works as intended
print(double("ab")) # abab — Python IGNORES the hint at runtime!
# The hints are stored for tools to read, not enforced:
print(double.__annotations__)Key idea
double("ab") still returns "abab". Their value is that a separate tool (mypy, or your editor) reads them and flags mismatches for you. This is called gradual typing: add hints where they help, skip them where they don't.Variables, parameters & returns
The syntax is name: type for variables and parameters, and -> type after the parentheses for what a function returns. A function that returns nothing is annotated -> None.
# Variable annotations
count: int = 0
name: str = "Ada"
ratio: float = 0.75
active: bool = True
# Parameters and the return type (after ->)
def area(width: float, height: float) -> float:
return width * height
# A function that returns nothing is annotated -> None
def log(message: str) -> None:
print(f"[LOG] {message}")
print(area(3, 4))
log("started")Tip
Collections, Optional & Union
Say what's inside a container with the built-in generics: list[int], dict[str, float], tuple[int, int]. When a value might be absent, or could be one of several types, combine types with the | operator.
# Built-in generics (Python 3.9+): say what's INSIDE the container
scores: list[int] = [98, 85, 91]
prices: dict[str, float] = {"pen": 1.5, "book": 9.0}
point: tuple[int, int] = (3, 4)
# "might be missing" -> use X | None (Python 3.10+)
def find_user(uid: int) -> str | None:
users = {1: "Ada", 2: "Linus"}
return users.get(uid) # returns str OR None
# "one of several types" -> use X | Y
def to_int(x: int | str) -> int:
return int(x)
print(find_user(1)) # Ada
print(find_user(99)) # None
print(to_int("42") + 1) # 43Older syntax you'll still see
Optional[str] instead of str | None, Union[int, str] instead of int | str, and imported List/Dict from typing. The modern | and lowercase built-ins are preferred in new code, but the old forms mean exactly the same thing.Generics, TypeVar & Protocol
A generic function works with many types while preserving the relationship between them. A TypeVar is a placeholder for "whatever type is passed in" — so first below returns the same type it received, and tools know it:
from typing import TypeVar
T = TypeVar("T") # "some type, chosen per call"
def first(items: list[T]) -> T: # returns the SAME type it received
return items[0]
n = first([10, 20, 30]) # tools infer n is an int
s = first(["a", "b", "c"]) # and s is a str
print(n, s)A Protocol describes a type by the methods it has, not the class it inherits from — static duck typing. Any object with a matching area() method satisfies SupportsArea, without ever importing or subclassing it:
from typing import Protocol
class SupportsArea(Protocol):
def area(self) -> float: ... # any object with area() qualifies
class Circle:
def __init__(self, r: float):
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
def describe(shape: SupportsArea) -> str:
return f"area = {shape.area():.2f}"
print(describe(Circle(2))) # Circle never mentions SupportsAreaTypedDict, Literal & Final
Three more tools cover common real-world shapes: TypedDict for a dict with known keys, Literal for a value restricted to an exact set, and Final for a constant that must not be reassigned.
from typing import TypedDict, Literal, Final
class User(TypedDict): # a dict with a known shape
name: str
age: int
ada: User = {"name": "Ada", "age": 36}
# Literal restricts a value to an exact set of choices
def move(direction: Literal["left", "right"]) -> None:
print("moving", direction)
# Final marks a constant that should never be reassigned
MAX_RETRIES: Final = 3
move("left")
print(ada["name"], "->", MAX_RETRIES)Tip
Literal is fantastic for "stringly-typed" APIs: mypy will reject move("up") because "up" isn't one of the allowed choices — turning a whole class of typo bugs into instant errors.Catching bugs with mypy
Hints only earn their keep when a tool checks them. mypy is the standard static type checker: it reads your annotations and reports mismatches without running the program. Here's code with a real bug:
# account.py
def withdraw(balance: int, amount: int) -> int:
return balance - amount
new_balance = withdraw(100, "20") # "20" is a str, not an int
print(new_balance)Run mypy and it points straight at the problem — line, argument, and expected type:
$ pip install mypy
$ mypy account.py
account.py:5: error: Argument 2 to "withdraw" has incompatible type
"str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)Watch out
TypeError on the subtraction — but mypy caught it at your desk, before a single line executed. On a large codebase that difference is the difference between a code-review comment and a 2 a.m. production page.Recap & quick check
Key takeaways
- Type hints annotate values but are NOT enforced at runtime — a separate tool (mypy or your editor) reads them.
- Syntax: name: type for variables and params, -> type for returns; use -> None for no return value.
- Describe containers with list[int], dict[str, float]; combine types with X | None and X | Y (3.10+).
- TypeVar makes functions/classes generic; Protocol matches types by their methods (structural typing).
- TypedDict types a dict's shape, Literal restricts to exact values, Final marks constants.
- Run mypy to catch type bugs statically, before the program ever runs.
Quick check
1. What happens to type hints when your program runs in CPython?
2. How do you annotate a function that takes an int and returns nothing?
3. Which modern hint means 'a string, or possibly None'?
4. What is a Protocol used for?
5. What does running mypy on your code do?
With types under your belt, let's tackle another precision tool for working with text. Next up: Module 27 — Regular Expressions.