# Type hints

> Annotate Python code with types, check it with mypy or Pyright, and use unions, generics, TypedDict, Literal and Protocol to catch bugs before running.

Python roadmap · Foundations · 17 min read · https://levelcode.org/roadmaps/python/type-hints

Python doesn't make you declare types, which makes small scripts quick to write. In larger code, the cost appears: you call a function and have to read its body to learn what it accepts and returns, and a `None` slips through to a place that can't handle it, crashing in production on a path no test covered. **Type hints** let you write down what types you expect, so your editor and a **type checker** can catch those mistakes before the code runs. This lesson covers the annotations you'll use every day, running a checker, and the more advanced tools: generics, `TypedDict`, `Literal` and `Protocol`.

## Annotations and what they do

Annotate parameters, return values and, where it helps, variables:

```python
def greet(name: str, times: int = 1) -> str:
    return ", ".join([f"Hello, {name}"] * times)


count: int = 0
names: list[str] = []
```

Two facts about hints matter more than any syntax:

1. **Python ignores them at runtime.** `greet(42)` still runs. Hints are information for tools and people, not checks.
2. **A type checker reads them** and reports code that can't be right, without running it. Your editor uses the same information for autocompletion and inline errors.

Hints are **gradual**: unannotated code is treated as "anything goes", so you can add them to an existing project one function at a time.

## Running a type checker

The two main checkers are **mypy** and **Pyright** (which powers VS Code's Pylance). Add one to your project:

```bash
uv add --dev mypy
uv run mypy src
```

Given this code:

```python
def find_user(users: dict[str, str], user_id: str) -> str | None:
    return users.get(user_id)


def shout(text: str) -> str:
    return text.upper()


shout(find_user({"u1": "ana"}, "u2"))
```

mypy reports:

```text
src/app.py:9: error: Argument 1 to "shout" has incompatible type "str | None"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
```

That's the bug that would otherwise surface as `AttributeError: 'NoneType' object has no attribute 'upper'` when a user isn't found. Run the checker in CI, and turn on strict mode as the codebase allows:

```toml
[tool.mypy]
strict = true
```

## The everyday types

Built-in collections take their element types in square brackets (Python 3.9+), and unions use `|` (3.10+):

```python
def average(values: list[float]) -> float: ...
def word_counts(text: str) -> dict[str, int]: ...
def bounds(values: list[int]) -> tuple[int, int]: ...       # exactly two ints
def tags(post_id: int) -> set[str]: ...
def parse_port(text: str) -> int | None: ...                 # an int, or None
def to_number(value: int | float | str) -> float: ...
```

`X | None` is the most important one: it forces every caller to handle the missing case before using the value. The checker understands **narrowing**, so after a check it knows the `None` is gone:

```python
port = parse_port(raw)
if port is None:
    raise ValueError(f"invalid port: {raw!r}")
print(port + 1)     # fine: port is int here
```

For parameters, prefer the **abstract** types from `collections.abc`, which say what you need rather than what you happen to pass:

```python
from collections.abc import Callable, Iterable, Mapping, Sequence


def total(prices: Iterable[float]) -> float:          # any iterable: list, tuple, generator
    return sum(prices)


def first_two(items: Sequence[str]) -> list[str]:     # needs indexing and len
    return list(items[:2])


def apply(values: list[int], fn: Callable[[int], int]) -> list[int]:
    return [fn(v) for v in values]
```

Accepting `Iterable[float]` instead of `list[float]` lets callers pass a generator or a tuple. Return concrete types (`list`, `dict`) so callers know exactly what they get.

## Type aliases and NewType

Give complex or meaningful types a name with the `type` statement (Python 3.12+):

```python
type Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None
type Matrix = list[list[float]]
```

When two values share a representation but must not be mixed up, such as user IDs and order IDs that are both strings, `NewType` makes them distinct to the checker at no runtime cost:

```python
from typing import NewType

UserId = NewType("UserId", str)
OrderId = NewType("OrderId", str)


def cancel(order_id: OrderId) -> None: ...


uid = UserId("u_42")
cancel(uid)    # error: Argument 1 has incompatible type "UserId"; expected "OrderId"
```

## Literal and TypedDict: precise shapes

`Literal` restricts a value to specific constants, which is handy for modes and options:

```python
from typing import Literal

def export(rows: list[dict], fmt: Literal["csv", "json"] = "csv") -> str: ...

export(rows, fmt="xml")    # error: Argument "fmt" has incompatible type "Literal['xml']"
```

`TypedDict` describes dictionaries with known keys, which is exactly what parsed JSON usually is:

```python
from typing import NotRequired, TypedDict


class UserPayload(TypedDict):
    id: int
    email: str
    nickname: NotRequired[str]


def display_name(user: UserPayload) -> str:
    return user.get("nickname") or user["email"]


display_name({"id": 1, "emal": "a@b.c"})   # error: missing key "email", extra key "emal"
```

A `TypedDict` is still a plain dict at runtime: it documents and checks shape without converting anything. If the data comes from outside your program (an HTTP request, a file), the checker can't know it's right; validate it at runtime with a library such as Pydantic, which you'll use in the FastAPI lesson.

## Generics: functions that keep types

Some functions work for any type, but the output type depends on the input. A **type parameter** expresses that link. Python 3.12 added a compact syntax:

```python
def first[T](items: Sequence[T]) -> T | None:
    return items[0] if items else None


n = first([3, 1, 2])        # checker knows n is int | None
s = first(["a", "b"])       # str | None
```

Classes can be generic too:

```python
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()


ints = Stack[int]()
ints.push(1)
ints.push("two")    # error: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int"
```

Bound a type parameter when the function needs some capability: `def largest[T: (int, float)](xs: list[T]) -> T` accepts lists of `int` or of `float`.

## Protocol: duck typing, checked

Python code often relies on **duck typing**: "if it has a `read` method, I can use it". `Protocol` describes such a shape, and any class with matching methods satisfies it, without inheriting from anything:

```python
from typing import Protocol


class Notifier(Protocol):
    def send(self, to: str, message: str) -> None: ...


class EmailNotifier:                      # no inheritance needed
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")


class FakeNotifier:
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, message: str) -> None:
        self.sent.append((to, message))


def confirm_order(notifier: Notifier, customer: str) -> None:
    notifier.send(customer, "Your order is confirmed")


confirm_order(EmailNotifier(), "ana@example.com")
confirm_order(FakeNotifier(), "ana@example.com")
```

This is Python's equivalent of Go's interfaces: the consumer defines the shape it needs, and anything that fits works, including test fakes.

> [!TIP]
> `Any` switches checking off for a value, and `typing.cast(T, value)` tells the checker "trust me". Both are occasionally necessary at the edges (untyped libraries, deserialised data), but every one is a place where bugs can hide. Keep them rare and close to where data enters your program.

## Practice

Here's an untyped module with a bug. Add type hints to everything, run mypy in strict mode, and fix what it finds.

```python
# inventory.py
def load(rows):
    stock = {}
    for row in rows:
        stock[row["sku"]] = int(row["qty"])
    return stock


def lookup(stock, sku):
    return stock.get(sku)


def reorder_list(stock, threshold=5):
    return [sku for sku, qty in stock.items() if qty < threshold]


def describe(stock, sku):
    qty = lookup(stock, sku)
    return sku + ": " + str(qty) + " left" + (" (reorder!)" if qty < 5 else "")


print(describe(load([{"sku": "PEN", "qty": "3"}]), "PENCIL"))
```

Use a `TypedDict` for the rows.

**Solution**

```python
# inventory.py
from collections.abc import Iterable
from typing import TypedDict


class Row(TypedDict):
    sku: str
    qty: str          # CSV-style input: quantities arrive as text


type Stock = dict[str, int]


def load(rows: Iterable[Row]) -> Stock:
    return {row["sku"]: int(row["qty"]) for row in rows}


def lookup(stock: Stock, sku: str) -> int | None:
    return stock.get(sku)


def reorder_list(stock: Stock, threshold: int = 5) -> list[str]:
    return [sku for sku, qty in stock.items() if qty < threshold]


def describe(stock: Stock, sku: str) -> str:
    qty = lookup(stock, sku)
    if qty is None:
        return f"{sku}: not stocked"
    flag = " (reorder!)" if qty < 5 else ""
    return f"{sku}: {qty} left{flag}"


print(describe(load([{"sku": "PEN", "qty": "3"}]), "PENCIL"))   # PENCIL: not stocked
```

Before the fix, strict mypy reports:

```text
inventory.py:23: error: Unsupported operand types for > ("int" and "None")  [operator]
inventory.py:23: note: Left operand is of type "int | None"
```

That's the real bug: looking up an unknown SKU returns `None`, and the original code crashed with `TypeError: '<' not supported between instances of 'NoneType' and 'int'`. Annotating `lookup` honestly as `int | None` forced the caller to handle the missing case. The type checker didn't find the bug by magic; writing the true return type did, and the checker made sure nobody forgot it.

## Key takeaways

- Type hints document intent and let checkers such as mypy and Pyright catch errors before running; Python ignores them at runtime.
- Use built-in generics (`list[str]`, `dict[str, int]`) and `X | None`, and let narrowing (`if x is None`) prove the missing case is handled.
- Accept abstract types (`Iterable`, `Sequence`, `Mapping`, `Callable`) and return concrete ones.
- `Literal`, `TypedDict`, `NewType` and `type` aliases make shapes and meanings precise; validate external data at runtime separately.
- Use `def f[T]` and `class C[T]` for generics, `Protocol` for checked duck typing, and keep `Any` and `cast` rare.

## Quiz

1. What happens when you call def area(width: float) -> float with area("wide") at runtime?

   A. Python raises a TypeError before calling it
   B. The string is converted to a float
   C. Python runs it anyway; hints aren't enforced at runtime, but a type checker would flag the call
   D. The function returns None

   **Answer: C.** Annotations are not runtime checks. Tools such as mypy report the mismatch; Python itself just runs the code, which then fails or misbehaves inside the function.

2. Why accept Iterable[float] rather than list[float] for a function that sums prices?

   A. Callers can pass lists, tuples, sets or generators, since the function only needs to iterate
   B. Iterable is faster
   C. list[float] isn't valid syntax
   D. It allows None values

   **Answer: A.** Requiring only what you use makes the function more widely usable. A list would force callers to build one even when they have a generator.

3. user_id and order_id are both strings, and passing one where the other is expected is a recurring bug. What helps the checker catch it?

   A. Literal
   B. Any
   C. TypedDict
   D. NewType

   **Answer: D.** NewType creates distinct types that the checker won't mix up, while staying plain strings at runtime.

4. A function takes any object with a send(to, message) method. How do you express that so test fakes qualify without inheriting from anything?

   A. Annotate the parameter as object
   B. Define a Protocol with send and annotate the parameter with it
   C. Use a TypedDict
   D. Use Callable[[str, str], None]

   **Answer: B.** A Protocol describes the required methods structurally; any class with a matching send method satisfies it, including fakes.
