Python, from the ground up Lesson 2 / 60

Type hints aren't optional anymore

How to write modern Python with type hints — for your own benefit, for IDE help, and because every code review now expects them.

Welcome to lesson two. We’re skipping the “hello world” prologue most Python tutorials open with, because if you’re reading lesson two of this course you can already write a print statement and we have better things to do. Instead, we’re starting with the single biggest cultural shift in Python over the last decade: type hints have stopped being optional.

In 2014, when PEP 484 introduced typing to Python, it was a contentious idea. Half the community was thrilled to finally get static checking; the other half wrote angry blog posts about how typing was a betrayal of Python’s dynamic soul. In 2026 that argument is over. Every major library is typed. Every job description for senior Python roles mentions mypy or pyright. Every code review at a competent shop will flag a missing type hint. AI assistants give you dramatically better suggestions when types are present. The runtime still doesn’t care — it’ll happily run untyped Python until the heat death of the universe — but the humans and the tools around the runtime now care a lot.

This lesson is the practical guide: what to type, how to type it, when to skip it, and what tools to point at your code. Everything from here on uses type hints by default, so let’s make sure we’re on the same page.

Why type — when the runtime ignores them

Python’s type hints are not enforced at runtime. This trips up everyone coming from Java, C#, TypeScript, Rust, or really any other typed language. You can write this:

def add(x: int, y: int) -> int:
    return x + y

print(add("hello", "world"))  # prints "helloworld". Yes, really.

Python sees int and shrugs. Python’s + operator works on strings, the function returns a string, the program runs. The type hint was a comment that the interpreter politely ignored.

So why bother? Four reasons, ranked by how often they save your bacon in practice.

1. Editor support. Modern editors (VS Code with Pylance, PyCharm, Cursor, Neovim with pyright-langserver) read type hints and give you autocomplete, parameter help, click-through to definitions, and red squiggles where you misuse a function. Without type hints all of that degrades to “guess.” Once you’ve worked in a fully-typed codebase you’ll never go back, the same way nobody who’s used a debugger goes back to print statements (mostly).

2. Catching bugs at edit-time. Tools like pyright and mypy will tell you, before you run the code, that you’re passing a str where a function expects an int, or that a function might return None and you forgot to handle it. A surprising fraction of “real” bugs in untyped Python codebases are exactly this category: a function that usually returns a value but sometimes returns None, and the caller never checked. Type hints make those impossible to ignore.

3. Self-documentation. A function signature like def fetch(client, query, timeout=None, retries=3) tells you almost nothing. The same function as def fetch(client: HttpClient, query: str, timeout: float | None = None, retries: int = 3) -> Response tells you exactly what to pass and what you’ll get back. The docstring still has work to do, but the contract is in the signature.

4. AI assistants get dramatically better. This one is new. Copilot, Claude, and the rest are all trained on typed code; the more types your codebase has, the more accurately they predict what to write next. Type errors flow into the AI’s context and shape its suggestions. We’ll come back to this.

The basics, modern syntax

If you learned typing from a 2018 blog post, this section will look slightly different from what you remember. Python 3.9 (list[int] syntax) and 3.10 (X | None syntax) cleaned up the ergonomics dramatically. We’ll use the modern forms throughout.

# Function arguments and return type
def greet(name: str) -> str:
    return f"Hello, {name}"

# Variable annotation (rarely needed; the type is usually inferred)
count: int = 0
ratio: float = 0.5

# Built-in container types — note: lowercase, no import
names: list[str] = ["Alice", "Bob"]
ages: dict[str, int] = {"Alice": 30, "Bob": 25}
coords: tuple[float, float] = (45.46, 9.19)
unique_ids: set[int] = {1, 2, 3}

# Optional values: prefer the `|` syntax over `Optional[X]`
def find_user(user_id: int) -> User | None:
    ...

# Union of multiple types
def parse(value: str | int | float) -> Decimal:
    ...

# Default values with types
def fetch(url: str, timeout: float = 5.0) -> bytes:
    ...

A few things worth noticing:

  • list[str] not List[str]. The capital-L List from typing is the pre-3.9 form. Drop it.
  • int | None not Optional[int]. Both are legal; the new form is shorter and doesn’t need an import.
  • tuple[float, float] is a tuple of exactly two floats. tuple[int, ...] is “a tuple of any number of ints” (the ... is genuinely the syntax).
  • Python now infers types in obvious cases. count = 0 is fine; count: int = 0 is redundant. Annotate variables only when the type isn’t obvious from the assignment, or when you’re declaring a variable without a value (buffer: list[bytes] = [] is fine; buffer = [] leaves the checker unsure what’s going in).

Functions, callables, and iterables

Real code passes functions around. Real code consumes generators. Type those too.

from collections.abc import Callable, Iterable, Iterator

# A function that takes a function
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

# A function that takes anything you can iterate over
def total(numbers: Iterable[float]) -> float:
    return sum(numbers)

# A generator function
def count_up(stop: int) -> Iterator[int]:
    n = 0
    while n < stop:
        yield n
        n += 1

Note we import from collections.abc, not typing. The typing.Iterable and friends are deprecated aliases as of 3.9; collections.abc is the modern home. Both work; the new code uses the new path.

Callable[[int, int], int] reads as “a callable that takes two ints and returns an int.” If you don’t care about the signature, Callable alone is fine, but you’re throwing away most of the value of typing it at all.

Generics — the new syntax

Sometimes you want a function that works on “a list of something” and returns “the something.” That something is a type variable, and 3.12 made declaring them much cleaner.

# Pre-3.12 (still works, still common in older code)
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

# 3.12+
def first[T](items: list[T]) -> T:
    return items[0]

# Same idea, with a class
class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

# Type aliases got the same treatment
type UserId = int
type Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None

Use the new syntax for new code. You’ll still see the old TypeVar form in libraries written before 3.12 — it’s not wrong, just verbose.

Special-purpose types worth knowing

from typing import Any, Literal, Final, TypedDict, NotRequired

# Any — the escape hatch. Means "I'm opting out of typing for this value."
# Use it sparingly; every Any is a hole in your type safety.
def parse_unknown(blob: Any) -> dict[str, Any]:
    ...

# Literal — only specific values are allowed
def set_mode(mode: Literal["read", "write", "append"]) -> None:
    ...

# Final — this name is set once and never reassigned
MAX_RETRIES: Final = 5

# TypedDict — a dict with known keys and value types
class UserDict(TypedDict):
    id: int
    name: str
    email: NotRequired[str]  # may or may not be present

TypedDict is genuinely useful when you’re parsing JSON and don’t want to define a full class. Literal makes “string-typed enum” parameters safer. Final is documentation that also catches accidental reassignment.

from __future__ import annotations

You will see this line at the top of older codebases:

from __future__ import annotations

Background: type hints used to be evaluated at function-definition time, which caused problems with forward references (a class referencing itself, types defined later in the file, etc.). The fix was PEP 563, “postponed evaluation of annotations,” which makes all annotations strings that are only evaluated when something asks for them.

In 3.10+ you mostly don’t need it. Forward references mostly just work. In 3.13 it remains opt-in but is not the default; the original PEP 649 “deferred evaluation” approach landed in 3.14 and changes the picture again. For this course you can ignore the __future__ import unless you hit a specific forward-reference error, in which case adding it usually fixes the problem.

Type checkers — pyright and mypy

Two real options.

pyright (Microsoft, written in TypeScript, ships in VS Code/Cursor as Pylance). Faster than mypy by a wide margin. Stricter defaults. Better at incremental checking. If you use VS Code, it’s already running on every keystroke. To run from the CLI:

uv tool install pyright
pyright src/

mypy (the original, run by Python’s typing community). More configurable, more conservative defaults, better at some edge cases involving generics. To run:

uv tool install mypy
mypy src/

Both produce similar results on most code; both will occasionally disagree, usually about how strict to be on edge cases. Pick one for a given project and configure it in pyproject.toml. Don’t run both — you’ll just argue with two tools instead of one.

A reasonable starter pyproject.toml block for pyright:

[tool.pyright]
include = ["src"]
typeCheckingMode = "standard"
pythonVersion = "3.13"

Set typeCheckingMode = "strict" once you’re comfortable. It’s a sharp jump in noise but it’s where serious codebases end up.

AI tip: Pyright + Copilot/Claude becomes far more useful once your code is typed — type errors flow into the AI’s context, and suggestions become much more accurate. Type your interfaces first, even if internals stay loosely typed. The boundaries between modules and the public APIs of your classes are where typing pays the most dividends, both for humans and for the model sitting in your editor.

When NOT to type

Typing isn’t free. Adding types to a 30-line throwaway script that you’ll delete tomorrow is a waste. Specifically:

  • REPL exploration. Nobody types in the REPL. Don’t start.
  • One-off scripts. A 50-line script that scrapes one webpage once is not where typing earns its keep.
  • Glue notebooks. Jupyter notebooks doing exploratory analysis. The pace is too fast and the lifecycle is too short.
  • Truly dynamic code. Some metaprogramming patterns genuinely defy static typing. When you find yourself fighting the type system for half an hour, an # type: ignore comment is fine.

What should be typed: any code that ships, any code in src/, any function someone else might call, any public API. The 80/20 rule applies: type the boundaries, the data classes, the function signatures. Internal one-line helpers can stay loose.

A worked example

Let’s type a small function the way you’d actually write it in 2026.

from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal


@dataclass
class LineItem:
    sku: str
    quantity: int
    unit_price: Decimal


def order_total(items: Iterable[LineItem], vat_rate: Decimal = Decimal("0.22")) -> Decimal:
    """Compute the VAT-inclusive total for a sequence of line items."""
    subtotal = sum((item.unit_price * item.quantity for item in items), start=Decimal("0"))
    return subtotal * (1 + vat_rate)

Three things worth noting:

  1. Iterable[LineItem] not list[LineItem]. The function doesn’t need a list; it iterates once. Accepting Iterable lets a caller pass a generator, a tuple, or a list. Be liberal in what you accept.
  2. Decimal not float for money. Always. Floats are for science; decimals are for money. pyright will catch you mixing them up.
  3. The return type is explicit. The function is short enough that the inference would have worked, but writing it down is a contract for callers.

Wrap-up

Types are no longer optional in modern Python. Use the new syntax (list[int], int | None, def f[T](...)). Use pyright or mypy. Type the boundaries of your modules at minimum, and your AI assistant will reward you for typing more. Don’t type the REPL or throwaway scripts.

Lesson 3 (Tuesday) is on three syntactic features that have changed how working Python is written: f-strings, the walrus operator, and pattern matching. We’ll write code, not theory.

Further reading

Search