datarekha

Type hints

Catch bugs before runtime, get real autocomplete, and write docs that don't rot — the pragmatic guide to typing Python today.

10 min read Intermediate Python Lesson 29 of 41

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.

annotated codedef greet(name: str)mypy / pyright (before run)flags greet(42) as a type errorPython runtimeignores the hints — runs greet(42) 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 | None in the type whenever a value can be missing, so callers must handle it.
  • Literal for exact value sets, TypedDict for dict schemas, Protocol for structural (duck) typing.
  • Type your data models and public APIs; skip throwaway scripts and trivial lambdas.

Practice

Quick check

0/3
Q1What is the modern (3.10+) equivalent of `Optional[int]`?
Q2What does a Protocol let you do that abc.ABC does not?
Q3What happens at runtime if a function declares `-> int` but returns a string?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
Which Python built-in types are mutable and which are immutable, and why does it matter?

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

How does the `@property` decorator work in Python, and when should you prefer it over a plain attribute?

`@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.

What are Abstract Base Classes in Python, how do you define them, and how do they relate to duck typing?

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.

What are .pyc files and what role does Python bytecode play?

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.

Related lessons

Explore further

Skip to content