The Story That Explains Type Hints
Now imagine the same recipe rewritten: "250 g flour, 180 g sugar, 3 large eggs." A newcomer can produce the cake correctly on day one. The recipe hasn't changed what it makes — it has changed who can read it and how fast they can trust it.
That is type hints. Python still runs your code the same way, but every function now says exactly what it wants and what it returns. Editors autocomplete. Bugs surface before you press "run." Teammates read your code without opening a REPL.
Type hints are Python's way of writing down what kind of value each variable, argument, or return should be. They live in the code as annotations — Python itself ignores them at runtime — but tools like mypy, pyright, PyCharm, and VS Code use them to catch bugs, power autocompletion, and generate documentation.
Type hints do NOT slow your code. They do NOT change what it does. They are optional documentation that tools verify for you. The result is code that's easier to refactor, safer to change, and self-explanatory to every new reader.
The Problem — Untyped Python at Scale
Here's the same function twice. One is written the way most beginners write Python. The other has type hints. Both do exactly the same thing at runtime — but the second one catches bugs your teammates would ship.
| Question a reader asks | Answer |
|---|---|
What is user? | Anyone's guess |
| Does it return None? | Read the whole body |
| Can I pass a dict? | Try it and see |
| Does IDE autocomplete work? | No — it can't know |
| Question a reader asks | Answer |
|---|---|
What is user? | A User object |
| Does it return None? | No — returns str |
| Can I pass a dict? | mypy blocks it before commit |
| Does IDE autocomplete work? | Full type-aware suggestions |
# BEFORE — untyped
def greet(user):
return "Hello " + user.name
# AFTER — typed
def greet(user: User) -> str:
return f"Hello {user.name}"
In a 10,000-line untyped codebase, refactoring a function signature means reading every caller manually. In a typed codebase, mypy points at every mismatch instantly. This is why every serious Python team since ~2020 — Instagram, Dropbox, Meta, Google — types everything they ship.
The Three Places Type Hints Live
count: int = 0
def upper(s: str)
-> None for procedures.
Callers get autocompletion on the returned value instantly.
-> list[str]
Anatomy of a Fully-Typed Function
def total(prices: list[float], tax: float = 0.0) -> float:
return sum(prices) * (1 + tax)
# Callable with confidence:
result: float = total([9.99, 14.50, 3.20], tax=0.08)
print(result) # 29.9052
Basic Types — Your Everyday Toolkit
| Type | Example Value | When to Use |
|---|---|---|
int | 42 | Whole numbers — counts, indices, IDs |
float | 3.14 | Decimals — prices, measurements, ratios |
str | "hello" | Any text |
bool | True | True/False flags |
bytes | b"raw" | Binary data, file contents, network buffers |
None | None | Absence of value — always as | None or return type |
list[X] | [1, 2, 3] | Ordered, mutable, all items of type X |
tuple[X, Y] | (1, "a") | Fixed-size, positional, each slot's type declared |
dict[K, V] | {"a": 1} | Mapping — keys of type K to values of type V |
set[X] | {1, 2, 3} | Unique, unordered, all items of type X |
# Variables
name: str = "Ada"
age: int = 30
tags: list[str] = ["dev", "python"]
scores: dict[str, float] = {"math": 91.5, "english": 88.0}
coord: tuple[float, float] = (51.5074, -0.1278) # exactly 2 floats
unique_ids: set[int] = {101, 102, 103}
# Function
def describe(person: dict[str, str]) -> str:
return f"{person['name']} ({person['role']})"
Write list[int], not List[int]. Write dict[str, int],
not Dict[str, int]. The lowercase built-in versions work from
Python 3.9 onward and are the modern standard.
Only import from typing for things that don't have a built-in
equivalent — Callable, Iterator, Protocol, etc.
Optional Values — Handling None Safely
A value that might be either a real value or None is one of the most
common patterns in Python. Type hints force you to declare and handle it —
no more silent NoneType has no attribute 'x' crashes at 3 AM.
# Modern syntax (Python 3.10+) — preferred
def find_user(user_id: int) -> User | None:
return db.get(user_id) # returns User OR None
# Legacy syntax (still valid on 3.8+)
from typing import Optional
def find_user(user_id: int) -> Optional[User]:
return db.get(user_id)
# Correct handling — mypy forces the None check
user = find_user(42)
if user is None:
print("not found")
else:
print(user.name) # mypy KNOWS user is User here — autocomplete works!
Null-pointer bugs cost real companies real money every year. In typed Python,
you cannot access an attribute on an X | None value without checking
it first — mypy blocks the code before it ships. That single guarantee
eliminates an entire class of production bugs.
Union Types — When a Value Could Be Several Things
Sometimes an argument could legitimately be one of several types. Use |
(or Union[...] on older Python) to declare the possibilities.
# Modern (3.10+)
def to_int(value: int | str | float) -> int:
return int(value)
# Narrowing with isinstance — mypy learns the type inside each branch
def describe_id(x: int | str) -> str:
if isinstance(x, int):
return f"numeric id: {x:06d}" # mypy: x is int here
return f"named id: {x.upper()}" # mypy: x is str here
Visual Diagram — The Type Checker Pipeline
mypy runs BEFORE your code executes — it's a static analyzer, not a runtime check. Your program never gets slower.
Running mypy
# Install once
pip install mypy
# Check a single file
mypy my_script.py
# Check a whole project — strict mode catches everything
mypy --strict src/
# In CI: fail the build on any type error
mypy --strict src/ tests/ || exit 1
Callable, Iterable, Generator — Function & Sequence Types
from typing import Callable, Iterable, Iterator, Generator
# A parameter that is a function itself
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
# ^ ^ ^
# argument types return type fn takes (int, int) and returns int
apply(lambda x, y: x + y, 3, 4) # 7
# Any iterable — accept lists, tuples, generators, sets, ...
def total(numbers: Iterable[float]) -> float:
return sum(numbers)
# Generator that yields ints — Iterator[int] is usually enough
def count_up(n: int) -> Iterator[int]:
for i in range(n):
yield i
Accept the widest reasonable type; return the most specific.
A function that takes Iterable[int] works with lists, tuples, generators,
and sets — anything iterable. But if you return a list, say
-> list[int] so callers can index it. This asymmetry makes your API
easy to use and easy to satisfy.
Structured Data — dataclass, TypedDict, NamedTuple
Real programs pass around structured data. Type hints shine here: your data shapes become first-class citizens the checker can verify.
__init__,
__repr__, and __eq__. First choice for domain objects.
dict whose keys and value types are known ahead of time.
Perfect for JSON API responses and settings.
from dataclasses import dataclass, field
from typing import TypedDict, NamedTuple
# 1) dataclass — the workhorse
@dataclass
class User:
id: int
name: str
email: str
active: bool = True
tags: list[str] = field(default_factory=list)
u = User(id=1, name="Ada", email="ada@x.io")
print(u) # User(id=1, name='Ada', email='ada@x.io', active=True, tags=[])
# 2) TypedDict — for JSON-shaped data you get from an API
class WeatherPayload(TypedDict):
city: str
temp_c: float
humidity: int
def summarize(w: WeatherPayload) -> str:
return f"{w['city']}: {w['temp_c']}°C"
summarize({"city": "London", "temp_c": 14.2, "humidity": 67})
# 3) NamedTuple — tiny, immutable, positional
class Point(NamedTuple):
x: float
y: float
p = Point(1.0, 2.0)
print(p.x, p.y) # 1.0 2.0
Generics — Functions That Preserve Types
Sometimes a function is type-preserving: whatever type comes in, the same type comes out. A generic TypeVar tells mypy "this is the same type in and out."
from typing import TypeVar, Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T | None:
return items[0] if items else None
x = first([1, 2, 3]) # mypy: int | None
y = first(["a", "b", "c"]) # mypy: str | None ← type flows through!
# Python 3.12+ has cleaner syntax with no TypeVar import:
def first[T](items: Sequence[T]) -> T | None:
return items[0] if items else None
One generic function, two different concrete types. The type "flows through" — no Any, no cast, no lost autocompletion.
Protocols — Duck Typing, Verified
Python's philosophy is "if it walks like a duck and quacks like a duck, it's a duck." Protocol lets you type-check that structural style without demanding inheritance.
from typing import Protocol
class SupportsWrite(Protocol):
def write(self, data: str) -> int: ...
def log(msg: str, sink: SupportsWrite) -> None:
sink.write(msg + "\n")
# Any object with a matching .write() method is accepted
# — no inheritance required, no registration required
import sys
log("starting", sys.stdout) # works — stdout has .write
from io import StringIO
buf = StringIO()
log("buffered", buf) # also works — StringIO has .write
class Silent:
def write(self, data: str) -> int:
return len(data) # our own class also matches
log("via silent", Silent()) # works
Three unrelated classes — no shared ancestor — all satisfy the Protocol just by having the right method shape. This is duck typing that mypy can verify.
Common Pitfalls (and How to Avoid Them)
| Mistake | What Happens | Fix |
|---|---|---|
Using Any everywhere |
Disables type checking silently | Use specific types; object or Unknown if truly unknown |
Mutable default: x: list = [] |
Shared state across calls — classic bug | Use None default then assign inside |
Confusing list and List |
Older imports mixed with 3.9+ syntax | Use list[X] (lowercase, 3.9+) everywhere |
| Forgetting return annotation | Callers don't get autocomplete | Always add -> Type (or -> None) |
| Wrong Optional syntax | Runtime works, but mypy confused | Use T | None (3.10+) or Optional[T] |
| Not narrowing before use | mypy error: "None has no attribute x" | Add if x is not None: guard |
| Circular imports for types | Runtime ImportError | Use from __future__ import annotations or TYPE_CHECKING |
# WRONG — mutable default
def add_tag(item: str, tags: list[str] = []) -> list[str]:
tags.append(item)
return tags # the SAME list persists across every call!
# RIGHT — sentinel None
def add_tag(item: str, tags: list[str] | None = None) -> list[str]:
tags = list(tags) if tags is not None else []
tags.append(item)
return tags
Real-World Example — Typed Weather API Client
Here's a small but production-shaped program: fetch weather from an API, parse it into a typed model, and return a summary. Every step is annotated, and mypy would catch every common mistake.
from __future__ import annotations
from dataclasses import dataclass
from typing import TypedDict, Iterable
import requests
# ── 1) Raw shape of the API response ───────────────────
class CurrentWeather(TypedDict):
temperature: float
windspeed: float
weathercode: int
class WeatherResponse(TypedDict):
current_weather: CurrentWeather
timezone: str
# ── 2) Domain model — clean, immutable, typed ───────────
@dataclass(frozen=True)
class CityWeather:
city: str
temp_c: float
wind_kmh: float
timezone: str
# ── 3) Fetch → parse → return typed object ──────────────
def fetch_weather(city: str, lat: float, lon: float) -> CityWeather | None:
params = {"latitude": lat, "longitude": lon, "current_weather": True}
r = requests.get("https://api.open-meteo.com/v1/forecast",
params=params, timeout=5)
if r.status_code != 200:
return None
data: WeatherResponse = r.json()
cw = data["current_weather"]
return CityWeather(
city=city,
temp_c=cw["temperature"],
wind_kmh=cw["windspeed"],
timezone=data["timezone"],
)
# ── 4) Consume — mypy checks every step ─────────────────
def format_report(entries: Iterable[CityWeather]) -> str:
lines: list[str] = ["City Temp Wind"]
for e in entries:
lines.append(f"{e.city:12s} {e.temp_c:5.1f}°C {e.wind_kmh:.1f} km/h")
return "\n".join(lines)
CITIES: list[tuple[str, float, float]] = [
("Mumbai", 19.0760, 72.8777),
("New York", 40.7128, -74.0060),
("London", 51.5074, -0.1278),
]
results: list[CityWeather] = [
w for c, lat, lon in CITIES
if (w := fetch_weather(c, lat, lon)) is not None
]
print(format_report(results))
Every mistake — passing a str where a float is expected, forgetting
to check the None return, typing a wrong dict key like "tempature",
returning the raw dict instead of the CityWeather —
mypy catches at development time. In a large team, this is the difference between
"it works on my machine" and "it works, everywhere, first try."
When to Use Type Hints — and When to Skip
Any is the honest answer.Golden Rules
Any —
you silently lose all the guarantees.
list[int], dict[str, float],
str | None. Skip List, Dict, Optional
unless you support Python < 3.9.
Iterable[X] or Sequence[X] for inputs;
return concrete types like list[X]. Accept widely, promise precisely.
Any unless you truly cannot describe the type.
Any silently disables checking. When you must be permissive,
use object so the checker still forces you to narrow before use.
--strict and fail the build on any error.
Types you don't check are types you don't have — the discipline only holds if it's automated.
dataclass for domain objects, TypedDict
for API-shaped dicts, NamedTuple for tiny immutable tuples,
and Protocol for structural interfaces.
def f(x: list = []) is a bug even
when typed. Use None and initialize inside the function.
from __future__ import annotations at the top of the file,
then use if TYPE_CHECKING: for pure-typing imports.