# Type Hints

> Source: https://learn-python.com/type-hints/
> Part of Learn Python, free to read.

Type hints are annotations Python itself ignores. A separate checker reads them and tells you when something cannot work.

```python
def greet(name: str) -> str:
    return f"Hello, {name}"

greet(42)        # runs fine — Python does not check
```

```bash
uv run mypy app.py
# app.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
```

The value is entirely in that second step. Hints with no checker in CI are documentation that drifts.

## The basics

```python
name: str = "ada"
count: int = 0
ratio: float = 0.5
active: bool = True

def total(items: list[int]) -> int:
    return sum(items)

def lookup(data: dict[str, int], key: str) -> int | None:
    return data.get(key)
```

Modern syntax uses the built-in types directly — `list[int]`, `dict[str, int]`, `tuple[int, str]`, `set[str]` — rather than importing `List`, `Dict` and friends from `typing`. Those older forms are deprecated; generated code still produces them because there is a decade of them in the training data.

```python
from typing import List, Optional     # old
def f(x: Optional[List[int]]) -> None: ...

def f(x: list[int] | None) -> None: ...   # current
```

## Optional and unions

`X | None` is Python's answer to nullability, and annotating it is what makes the checker catch the most common runtime error:

```python
def find_user(uid: str) -> User | None:
    ...

user = find_user("1")
print(user.email)          # error: Item "None" of "User | None" has no attribute "email"

if user is not None:
    print(user.email)      # fine — narrowed
```

That narrowing works with `is None`, `isinstance`, and truthiness, exactly as a reader would expect.

:::warn A default of `None` does not make a parameter optional in the type
```python
def f(items: list[int] = None) -> None: ...        # mypy error
def f(items: list[int] | None = None) -> None: ... # correct
```
And the value should be `None`, never `[]` — a [mutable default](/review/failure-modes/) is shared across every call.
:::

## Functions and callables

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

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

def chunks(data: Sequence[int], size: int) -> Iterator[list[int]]:
    for i in range(0, len(data), size):
        yield data[i : i + size]
```

Two habits worth adopting:

- **Accept the widest type you can, return the narrowest.** Take `Iterable[int]` if you only iterate; return a concrete `list[int]` so callers can index it.
- **Import ABCs from `collections.abc`**, not `typing` — the `typing` versions are deprecated aliases.

## Protocols — typing by shape

Python's duck typing has a type-level equivalent. A `Protocol` says "anything with these methods", with no inheritance required:

```python
from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def cleanup(resource: SupportsClose) -> None:
    resource.close()

cleanup(open("x.txt"))       # a file has .close() — accepted
cleanup(my_connection)       # so does this — accepted
```

This is how you type an interface without forcing every implementation to inherit from your base class. It is the most Pythonic part of the type system and the most under-used.

## TypedDict

For dictionaries with a known shape — a JSON payload, a config:

```python
from typing import TypedDict, NotRequired

class UserRecord(TypedDict):
    id: str
    email: str
    age: NotRequired[int]        # may be absent

def send(user: UserRecord) -> None:
    print(user["email"])
    print(user["nope"])          # error: no key "nope"
```

Useful when the data genuinely is a dict. If you control the shape, a [dataclass](/dataclasses/) is usually better — attribute access, defaults and validation all come free.

## Generics

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

reveal_type(first([1, 2, 3]))      # int | None
reveal_type(first(["a"]))          # str | None
```

Python 3.12 introduced this inline syntax; earlier versions need an explicit `TypeVar`. `reveal_type()` is a checker-only helper that prints the inferred type — invaluable when a hint is not doing what you expected.

## Setting up the checker

```toml pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
files = ["src", "tests"]

# existing debt, opted out module by module and deleted as it is paid down
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = true
```

```bash
uv run mypy src
```

On an existing codebase, `strict = true` produces a lot of errors at once. The override block above is the way through: get to green immediately, then delete entries one at a time. A visibly shrinking list beats an invisible surrender.

## Where hints do not help

Annotations are erased at runtime, so they constrain your code and not your data:

```python
def load(raw: str) -> dict[str, int]:
    return json.loads(raw)      # could be anything at runtime; the hint is a claim
```

For data arriving from outside your program — a request body, a config file, a database row — use a validating library (`pydantic`, `attrs` with validators) so something actually checks. A hint is a promise; a parse is a verification.

## Exercise

```python
# Add type hints to all of these, then reason about what mypy would say:

def parse_scores(raw):
    return {name: int(value) for name, value in (p.split("=") for p in raw.split(","))}

def best(scores, minimum=0):
    winner = None
    for name, value in scores.items():
        if value >= minimum and (winner is None or value > scores[winner]):
            winner = name
    return winner

print(best(parse_scores("ada=90,alan=85,grace=95"), 80))
```

## Common questions

### Do type hints slow Python down?

No — they are ignored at runtime beyond being stored in `__annotations__`. The only cost is running the checker, which happens at development time and in CI, not in production.

### Should I annotate everything?

Annotate function signatures, especially public ones — they are the contract, and the checker uses them at every call site. Local variables usually need no annotation because inference handles them; add one only when the checker cannot work it out or a reader would struggle.

### mypy or pyright?

`pyright` is faster and stricter by default and powers the VS Code experience; `mypy` is the reference implementation with better tooling for incremental adoption on a legacy codebase. Either is fine. Running both is not worth the friction.
