Type hints
Catch bugs before runtime, get real autocomplete, and write docs that don't rot — the pragmatic guide to typing Python today.
What you'll learn
- The basic annotations and the modern 3.10+ syntax (list[str], X | None)
- Optional and why "could be None" must be in the type
- Literal, TypedDict, and Protocol for richer types
- Generics with TypeVar, and running mypy / pyright
Before you start
Python is dynamically typed, and type hints do not change that. What they add is a static layer on top: the interpreter ignores them entirely, but tools like mypy and pyright read them to catch type bugs before your code ever runs. The payoff is threefold — genuine autocomplete in your editor, far fewer “NoneType has no attribute” surprises in production, and documentation that a machine keeps honest instead of a tired reviewer. The one idea to hold throughout is that hints are checked, not enforced.
The basic annotations
def greet(name: str) -> str: # one str in, one str out
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
age: int = 30 # variables can be annotated too
names: list[str] = ["Aarav", "Priya"] # parameterised containers
ages_by_name: dict[str, int] = {"Aarav": 28}
The annotations are optional, and the runtime pays them no attention — greet(42) does not raise. The picture below is the whole mental model: you write hints, the type checker reads them before the program runs and flags the bad call, while the interpreter shrugs and runs it anyway.
The modern syntax
The older Optional[int], Union[int, str], List[int], and Dict[str, int] forms from the typing module have been superseded. Two version milestones matter: since Python 3.9 you use the lowercase built-in generics, and since 3.10 you use the | union operator:
Old style Modern style
Optional[int] int | None # 3.10+
Union[int, str] int | str # 3.10+
List[int] list[int] # 3.9+
Dict[str, int] dict[str, int] # 3.9+
Tuple[int, str] tuple[int, str] # 3.9+
Use the modern form — it is terser, and the old versions linger only for backward compatibility. Here it is on a real function:
def find_user(user_id: int) -> dict | None:
"""Return the user dict, or None if not found."""
users = {1: {"name": "Aarav"}, 2: {"name": "Priya"}}
return users.get(user_id)
def get_name(user_id: int) -> str:
user = find_user(user_id)
if user is None:
return "unknown"
return user["name"]
print(get_name(1))
print(get_name(99))
Aarav
unknown
Optional means “could be None”
That | None is not decoration — it is the most valuable hint you can write. It tells the type checker the value might be missing, which forces every caller to handle the None before reaching for an attribute:
u = get_user(1) # u: User | None
u.name # type error — u could be None!
if u is not None:
u.name # OK here — the type is narrowed to User
That is precisely how mypy catches the infamous AttributeError: 'NoneType' object has no attribute 'name' before you ship it. Leave the | None off, and the checker assumes a guaranteed User, and the crash waits for production.
Literal, TypedDict, Protocol
Three more constructs from typing carry most real-world typing. Literal restricts a value to an exact set — enum-like, without a full Enum class, and your editor autocompletes the allowed values:
from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARN", "ERROR"]
def log(message: str, level: LogLevel = "INFO") -> None:
print(f"[{level}] {message}")
log("starting up")
log("disk full", "ERROR")
# log("oops", "FATAL") # a checker flags this — "FATAL" is not in the Literal
[INFO] starting up
[ERROR] disk full
TypedDict types the keys of dict-shaped data you cannot reshape — JSON straight from an API, say:
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
is_active: bool
def format_user(u: UserDict) -> str:
return f"{u['name']} <{u['email']}>"
data: UserDict = {"id": 1, "name": "Aarav", "email": "a@x.com", "is_active": True}
print(format_user(data))
Aarav <a@x.com>
And Protocol is typed duck-typing: any class with the right methods satisfies it, with no inheritance required. This is how numpy and pandas accept “anything that quacks like an array”:
from typing import Protocol
class HasArea(Protocol):
def area(self) -> float: ...
def print_area(shape: HasArea) -> None:
print(f"area: {shape.area()}")
class Square:
def __init__(self, side): self.side = side
def area(self): return self.side ** 2
print_area(Square(5)) # works — Square has area(); no `class Square(HasArea)` needed
For new code prefer @dataclass or Pydantic over TypedDict, but when you are handed a dict you cannot change, TypedDict is the bridge.
Generics with TypeVar
When a function is generic over a type — its output type depends on its input type — a TypeVar expresses that link:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
"""Return the first item, or None if empty — return type matches input."""
return items[0] if items else None
n = first([1, 2, 3]) # the checker infers: n is int | None
s = first(["a", "b"]) # the checker infers: s is str | None
print(n, s, first([]))
1 a None
This is exactly how dict.get and list.append are typed in the standard library — the return type adapts to what you pass in. You will rarely write generic classes, but you consume them every day.
A real example — typing a response handler
Realistic typing on a function that takes messy input and produces clean output — note the Literal documenting the legal statuses inline, and Order | None warning that parsing can fail:
from dataclasses import dataclass
from typing import Literal
@dataclass
class Order:
id: int
status: Literal["pending", "paid", "shipped", "cancelled"]
total_cents: int
user_id: int
def parse_order(raw: dict) -> Order | None:
"""Convert an API payload to an Order, or None if it is malformed."""
try:
return Order(
id=int(raw["id"]),
status=raw["status"],
total_cents=int(raw["total_cents"]),
user_id=int(raw["user_id"]),
)
except (KeyError, TypeError, ValueError):
return None
def format_for_export(orders: list[Order]) -> list[dict[str, str]]:
return [
{"id": str(o.id), "status": o.status, "total": f"${o.total_cents / 100:.2f}"}
for o in orders
]
raw_payloads = [
{"id": "1", "status": "paid", "total_cents": "9900", "user_id": "42"},
{"id": "2", "status": "pending", "total_cents": "1500", "user_id": "42"},
{"badly": "shaped"}, # invalid
]
orders = [o for o in (parse_order(r) for r in raw_payloads) if o is not None]
print(format_for_export(orders))
[{'id': '1', 'status': 'paid', 'total': '$99.00'}, {'id': '2', 'status': 'pending', 'total': '$15.00'}]
The malformed third payload returned None and was filtered out, exactly as the Order | None type warns it might. The types are doing double duty: documenting intent for the human and giving the checker enough to catch a misuse.
The tools, and a strategy
Two checkers dominate. mypy is the original, configured via mypy.ini or pyproject.toml; pyright (Microsoft, written in Node, and the engine behind VS Code’s Pylance) is much faster on large codebases. Coverage is similar — pick one and run it in CI:
pip install mypy && mypy mypackage/
# or
pip install pyright && pyright mypackage/
In one breath
- Hints are static metadata — checked by mypy/pyright, ignored by the interpreter.
- Use the modern syntax:
list[str],dict[str, int],int | None. - Put
| Nonein the type whenever a value can be missing, so callers must handle it. Literalfor exact value sets,TypedDictfor dict schemas,Protocolfor structural (duck) typing.- Type your data models and public APIs; skip throwaway scripts and trivial lambdas.
Practice
Quick check
What’s next
You have Python’s data and typing primitives. The next track turns to concurrency — and it opens with the one feature that shapes all of it: the GIL, and what it does and does not let two threads do at once.
Practice this in an interview
All questionsImmutable types — int, float, bool, str, bytes, tuple, frozenset — cannot be changed after creation; operations return new objects. Mutable types — list, dict, set, bytearray — can be changed in place. Mutability determines hashability (only immutables can be dict keys/set members), function side-effect behaviour, and thread-safety considerations.
`@property` turns a method into a descriptor that Python calls automatically on attribute access, letting you add validation or computation behind a dot-access interface without changing callers. Use it when a value is derived, needs guarding, or must be lazily computed — not as a default for every attribute.
Abstract Base Classes (ABCs) from the `abc` module let you declare interfaces with `@abstractmethod` — any concrete subclass that does not implement all abstract methods raises `TypeError` at instantiation. ABCs coexist with duck typing: you can register unrelated classes as virtual subclasses without inheritance, and `isinstance` checks will pass.
When Python imports a module, it compiles the source to platform-independent bytecode and caches it in a .pyc file inside __pycache__. On subsequent imports the cached bytecode is loaded directly if the source is unchanged, skipping the parse-and-compile step. Bytecode is not machine code — it is still interpreted by the CPython virtual machine.