Type Hints and Modern Python

Article · 13:00

Type hints are annotations, and Python does not enforce them at runtime - they are for readers, editors and a checker like mypy. That is a feature: they cost nothing when the program runs, and this is where they earn their place.

This lesson has 9 runnable examples. Press Try yourself! under any of them to open it in the editor.

Type Hints and Modern Python

Type hints are annotations the interpreter stores and ignores. They exist for the reader, the editor and a type checker you run separately. Used well they are documentation that cannot go stale; used badly they are noise.

Nothing checks this

PY Example 1 6 lines
def double(n: int) -> int:
    return n * 2


print(double("abc"))               # 'abcabc' - no error, no warning
print(double.__annotations__)      # {'n': <class 'int'>, 'return': <class 'int'>}

Open this example in the editor and change it. Nothing you type is saved.

The annotation is a claim, not a guard. mypy or pyright reads it and tells you double("abc") is wrong, before you run anything. Python itself does not.

The annotations are readable at run time if you want them, which is how dataclasses, pydantic and FastAPI work - they inspect the annotations and generate behaviour from them.

Writing the common shapes

PY Example 2 22 lines
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence


def first(values: Sequence[int]) -> int | None:
    return values[0] if values else None


def totals(rows: Iterable[Mapping[str, int]]) -> dict[str, int]:
    out: dict[str, int] = {}
    for row in rows:
        for key, value in row.items():
            out[key] = out.get(key, 0) + value
    return out


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


print(first([1, 2]), first([]))
print(totals([{"a": 1}, {"a": 2, "b": 3}]))        # {'a': 3, 'b': 3}
print(apply([1, 2], str))                          # ['1', '2']

Open this example in the editor and change it. Nothing you type is saved.

str | None replaces Optional[str]; list[int] replaces List[int]. The typing module's capitalised aliases are deprecated and you should not write new code with them.

The important distinction is what to accept and what to return:

PY Example 3 2 lines
def good(values: Iterable[int]) -> list[int]:
    return sorted(values)

Open this example in the editor and change it. Nothing you type is saved.

Accept the most general thing you can use - Iterable, Sequence, Mapping - so a caller can pass a list, a tuple, a generator or a dict view. Return the most specific thing you actually produce - list, dict - so the caller knows what they can do with it. Accepting list[int] when you only iterate it needlessly rejects a generator.

Generics, protocols and literals

A generic function keeps the relationship between argument and result:

PY Example 4 12 lines
from typing import TypeVar
from collections.abc import Sequence

T = TypeVar("T")


def first_or(values: Sequence[T], default: T) -> T:
    return values[0] if values else default


print(first_or([1, 2], 0))              # a checker knows this is an int
print(first_or(["a"], "z"))             # and this is a str

Open this example in the editor and change it. Nothing you type is saved.

Without the TypeVar you would have to annotate the return as object, and every caller would have to cast it back.

A Protocol is structural: anything with the right shape satisfies it, with no inheritance and no registration.

PY Example 5 24 lines
from typing import Protocol, runtime_checkable


@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...


class Connection:
    def close(self) -> None:
        print("closed")


class Unrelated:
    pass


def shut(resource: Closeable) -> None:
    resource.close()


shut(Connection())                      # 'closed' - it just fits
print(isinstance(Connection(), Closeable))     # True
print(isinstance(Unrelated(), Closeable))      # False

Open this example in the editor and change it. Nothing you type is saved.

Connection never heard of Closeable. That is the point: protocols describe duck typing in a way a checker can verify, which is what Python needed rather than Java's interfaces.

Literal narrows to specific values; TypedDict describes a dict shape:

PY Example 6 17 lines
from typing import Literal, TypedDict


class User(TypedDict):
    id: int
    name: str
    role: Literal["admin", "editor", "viewer"]


def promote(user: User, to: Literal["admin", "editor"]) -> User:
    return {**user, "role": to}


u: User = {"id": 1, "name": "Aisha", "role": "viewer"}
print(promote(u, "editor"))
# {'id': 1, 'name': 'Aisha', 'role': 'editor'}
# promote(u, "owner")   - a checker rejects this; Python would not

Open this example in the editor and change it. Nothing you type is saved.

A TypedDict is a plain dict at run time. It costs nothing and describes the JSON you actually receive, which a dataclass cannot do without a conversion step.

Narrowing, and the checker's view of control flow

PY Example 7 9 lines
def describe(value: int | str | None) -> str:
    if value is None:
        return "nothing"                # here, value is None
    if isinstance(value, int):
        return f"the number {value + 1}"  # here, it is int - + 1 is allowed
    return value.upper()                 # here, it must be str


print(describe(None), describe(1), describe("hi"))     # nothing the number 2 HI

Open this example in the editor and change it. Nothing you type is saved.

A checker follows is None, isinstance, assert and early returns, and narrows the type in each branch. That is why the value.upper() at the end needs no cast: there is nothing else it could be.

When the checker cannot see what you know, assert is the honest tool - it narrows and it checks at run time:

PY Example 8 7 lines
def load(mapping: dict[str, str], key: str) -> int:
    raw = mapping.get(key)
    assert raw is not None, f"{key} must be set"
    return int(raw)


print(load({"port": "5432"}, "port"))   # 5432

Open this example in the editor and change it. Nothing you type is saved.

typing.cast also narrows, but it is a lie to the checker with no run-time effect. Use it only at a boundary where you genuinely know more than the checker can.

A worked example

PY Example 9 94 lines
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Literal, Protocol, TypedDict, TypeVar

Status = Literal["draft", "published", "archived"]


class LessonRow(TypedDict):
    """The shape of a row as it arrives from the database driver."""
    id: int
    title: str
    status: Status
    minutes: int


@dataclass(frozen=True, slots=True)
class Lesson:
    id: int
    title: str
    status: Status
    minutes: int

    @classmethod
    def from_row(cls, row: LessonRow) -> "Lesson":
        return cls(id=row["id"], title=row["title"],
                   status=row["status"], minutes=row["minutes"])


class HasMinutes(Protocol):
    """Structural: anything with a minutes attribute will do, whether
    or not it is a Lesson."""
    minutes: int


T = TypeVar("T")


def total_minutes(items: Iterable[HasMinutes]) -> int:
    return sum(item.minutes for item in items)


def take(values: Sequence[T], count: int) -> list[T]:
    """Generic, so the element type survives the call."""
    return list(values[:count])


def published(lessons: Iterable[Lesson]) -> list[Lesson]:
    return [lesson for lesson in lessons if lesson.status == "published"]


ROWS: list[LessonRow] = [
    {"id": 1, "title": "Selectors", "status": "published", "minutes": 7},
    {"id": 2, "title": "The box model", "status": "published", "minutes": 7},
    {"id": 3, "title": "Container queries", "status": "draft", "minutes": 6},
    {"id": 4, "title": "Old notes", "status": "archived", "minutes": 3},
]

lessons = [Lesson.from_row(row) for row in ROWS]

live = published(lessons)
print(len(live), total_minutes(live))              # 2 14
print([lesson.title for lesson in take(live, 1)])  # ['Selectors']


# The protocol accepts something that is not a Lesson at all.
@dataclass
class Quiz:
    minutes: int
    questions: int


print(total_minutes([Quiz(5, 10), Quiz(3, 6)]))    # 8
print(total_minutes(lessons + []))                 # 23

# Narrowing in practice.
def summarise(item: Lesson | Quiz | None) -> str:
    if item is None:
        return "nothing"
    if isinstance(item, Quiz):
        return f"quiz, {item.questions} questions"
    return f"lesson {item.id}: {item.title}"


for item in (None, Quiz(5, 10), lessons[0]):
    print(summarise(item))
# nothing
# quiz, 10 questions
# lesson 1: Selectors

# And the reminder that none of this is enforced at run time.
bad: LessonRow = {"id": "not an int", "title": "x", "status": "nope", "minutes": 1}
print(Lesson.from_row(bad))
# Lesson(id='not an int', title='x', status='nope', minutes=1)
# A checker rejects that line. Python builds it happily.

Open this example in the editor and change it. Nothing you type is saved.

The final block is the honest ending. Two deliberate type errors, no complaint from the interpreter, an object built with a string where an integer belongs. Hints find that at the point you write it, in your editor, for free - but only if you actually run a checker.

Where hints earn their place

Not everywhere. The cost is real: annotations add noise, they go stale if nothing checks them, and a complex generic signature can be harder to read than the function it describes.

Where they pay:

  • Function signatures at module boundaries. Anything another file calls.
  • Anything returning None sometimes. int | None is the single most useful hint there is, because it forces the caller to think about the empty case.
  • Data shapes. TypedDict for JSON, dataclass for domain objects.
  • Containers whose contents are not obvious. dict[str, list[int]] saves a reader from scrolling.

Where they do not:

  • Short local variables. count = 0 needs no : int.
  • Obvious self. Never annotate it.
  • Throwaway scripts that nobody will maintain.

Run the checker in CI. A hint nobody checks is a comment that is allowed to lie, which is worse than no comment at all.

When it goes wrong

Symptom Cause
A wrong type caused no error Hints are not enforced; run a checker
TypeError: 'type' object is not subscriptable list[int] on a very old Python
NameError annotating a class inside itself Quote it, or from __future__ import annotations
A checker rejects a generator argument Parameter says list; use Iterable
Optional[X] still allows None after a check The check is not one the checker narrows on
isinstance with a Protocol raised It needs @runtime_checkable
Annotations slowed down imports Heavy imports for types; put them under TYPE_CHECKING
cast did not convert anything It never does; it only tells the checker

A check you can run

Take any function in your codebase that sometimes returns None and annotate its return type as -> X | None. Then run mypy on the file.

Every caller that uses the result without checking for None is now an error, listed with a line number. That list is not noise - it is the set of places your program will raise AttributeError: 'NoneType' object has no attribute ... given the wrong input. One annotation, and the checker found all of them without running the code.