Python Advance 📂 Important concepts · 1 of 6 41 min read

Type Hints & Annotations in Python

Master Python's type hints from the ground up. Learn variable, argument, and return annotations, then advance to Optional, Union, Callable, dataclass, TypedDict, NamedTuple, TypeVar generics, and Protocol structural typing. Includes a visual mypy pipeline diagram, an anatomy-of-a-typed-function chart, a real weather-API client with fully typed data flow, common pitfalls, and eight golden rules for team codebases.

Section 01

The Story That Explains Type Hints

The Recipe Card Without Measurements
Imagine you inherit a family recipe. It says: "add flour, then sugar, then eggs, mix and bake." No amounts. No units. Cups? Grams? One egg or twelve? You guess, and half the time the cake fails. Every new cook who joins the kitchen makes the same mistakes you did.

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.

🧠
The Core Insight

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.


Section 02

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.

❌ Untyped — Ambiguous
Question a reader asksAnswer
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
✅ Typed — Self-Documenting
Question a reader asksAnswer
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}"
⚠️
The Hidden Cost of Untyped Code

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.


Section 03

The Three Places Type Hints Live

👤
1 — Variable Annotations
name: type = value
Attach a type to a variable at definition. Used for module-level constants, class attributes, and locals where the type isn't obvious from the value.
count: int = 0
🛠️
2 — Argument Annotations
def fn(x: type):
Declare what each parameter must be. Callers who pass the wrong thing get flagged by mypy before the code ever runs.
def upper(s: str)
↩️
3 — Return Annotations
-> type:
State what the function gives back. -> None for procedures. Callers get autocompletion on the returned value instantly.
-> list[str]

Anatomy of a Fully-Typed Function

🔍 Diagram — The Parts of a Typed Function
def total ( prices : list[float] , tax : float = 0.0 ) -> float : keyword function name argument type (list of floats) default value return type (what caller gets) Every colon (:) introduces a type. The arrow (->) always precedes the return type.
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
OUTPUT
29.9052

Section 04

Basic Types — Your Everyday Toolkit

TypeExample ValueWhen to Use
int42Whole numbers — counts, indices, IDs
float3.14Decimals — prices, measurements, ratios
str"hello"Any text
boolTrueTrue/False flags
bytesb"raw"Binary data, file contents, network buffers
NoneNoneAbsence 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']})"
💡
Python 3.9+ Syntax

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.


Section 05

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!
⚠️
The Billion-Dollar Mistake, Neutralized

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.


Section 06

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

Section 07

Visual Diagram — The Type Checker Pipeline

📈 Diagram — How mypy Turns Annotations Into Errors
SOURCE your .py file with type hints PARSE build AST extract annotations INFER propagate types through expressions CHECK match signatures report mismatches ✓ SUCCESS: 0 errors ship with confidence ✗ ERRORS fix & retry Python runtime ignores hints entirely

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
OUTPUT — Example error report
my_script.py:14: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type] my_script.py:22: error: Item "None" of "User | None" has no attribute "name" [union-attr] Found 2 errors in 1 file (checked 1 source file)

Section 08

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
💡
Prefer Iterable in Arguments, list in Returns

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.


Section 09

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.

📦
dataclass
object-shaped data
Mutable record with methods, defaults, and auto-generated __init__, __repr__, and __eq__. First choice for domain objects.
📂
TypedDict
dict with declared keys
A dict whose keys and value types are known ahead of time. Perfect for JSON API responses and settings.
📈
NamedTuple
immutable, positional
Tiny, immutable, tuple-shaped record. Ideal for coordinates, points, key/value pairs where you don't want mutation.
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
OUTPUT
User(id=1, name='Ada', email='ada@x.io', active=True, tags=[]) 1.0 2.0

Section 10

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
🔄 Diagram — How a TypeVar Flows Through a Function
first([1, 2, 3]) T = int first(["a","b","c"]) T = str first[T] Sequence[T] → T | None T is a placeholder bound at each call int | None mypy knows: int str | None mypy knows: str

One generic function, two different concrete types. The type "flows through" — no Any, no cast, no lost autocompletion.


Section 11

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
🎵 Diagram — Structural Typing (Protocol Matching)
SupportsWrite (Protocol) write(data: str) -> int just a shape — no inheritance sys.stdout has .write(str) -> int ✓ matches StringIO has .write(str) -> int ✓ matches Silent (yours) has .write(str) -> int ✓ matches

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.


Section 12

Common Pitfalls (and How to Avoid Them)

MistakeWhat HappensFix
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

Section 13

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))
OUTPUT
City Temp Wind Mumbai 28.4°C 12.3 km/h New York 15.1°C 8.7 km/h London 11.6°C 6.2 km/h
🏆
What Type Hints Bought Us Here

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."


Section 14

When to Use Type Hints — and When to Skip

Library / API Boundaries
Any function called from outside its module should be fully typed. Callers use the annotations to understand your API without reading the code.
public functions, class methods
Team Codebases
Type hints are documentation that never goes stale — mypy fails CI if they lie. Essential when more than one person touches the code.
web apps, backend services, SDKs
Long-Lived Projects
Anything that will be refactored later. Types make refactoring safe — mypy points at every affected call site instantly.
production, mature codebases
Throwaway Scripts
A 20-line script you'll never run again doesn't need annotations. Save your energy for the code that will outlive today.
one-off automation, quick REPL
Jupyter Exploration
Fluid data exploration is faster untyped. Type things later, when the code graduates to a module.
notebooks, prototyping
Highly Dynamic Metaprogramming
Code that returns different types based on runtime configuration is genuinely hard to type. Sometimes Any is the honest answer.
plugin systems, ORMs

Section 15

Golden Rules

🔧 Type Hints — Non-Negotiable Rules
1
Type every public function: both arguments and return. A missing return annotation means mypy assumes Any — you silently lose all the guarantees.
2
Use modern syntax: list[int], dict[str, float], str | None. Skip List, Dict, Optional unless you support Python < 3.9.
3
Prefer Iterable[X] or Sequence[X] for inputs; return concrete types like list[X]. Accept widely, promise precisely.
4
Never use 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.
5
Always run mypy in CI with --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.
6
Use dataclass for domain objects, TypedDict for API-shaped dicts, NamedTuple for tiny immutable tuples, and Protocol for structural interfaces.
7
Never use mutable defaultsdef f(x: list = []) is a bug even when typed. Use None and initialize inside the function.
8
When a type appears at module level but would cause circular imports at runtime, guard the import: from __future__ import annotations at the top of the file, then use if TYPE_CHECKING: for pure-typing imports.