Python Аннотации типов: mypy, Protocol, generics, строгость в реальном проекте
0%

Аннотации типов: mypy, Protocol, generics, строгость в реальном проекте

Аннотации типов: mypy, Protocol, generics, строгость в реальном проекте

В динамическом языке ошибка типа обнаруживается в тот момент, когда интерпретатор доходит до соответствующей строки. На ста строках это не проблема. На ста тысячах — это ночной алерт: функция, которая раз в сутки получает None вместо Decimal, падает в отчёте за прошлый месяц, а не на code review.

Аннотации типов в Python решают ровно эту задачу: они переносят часть ошибок из рантайма в момент до коммита. Но делают это иначе, чем в Java, Go или C#. Там типы — часть компиляции: код без корректных типов просто не собирается. В Python типы — внешний, необязательный, опциональный по строгости слой, который читает отдельная программа (mypy, pyright), а сам интерпретатор к ним равнодушен. Отсюда и вся специфика: у типизации Python нет гарантий, зато есть градиент — можно типизировать один модуль строго, второй никак, и это будет работать.

Эта статья — про то, как устроен этот слой изнутри и как жить с ним в проекте, где сто тысяч строк уже написаны без единой аннотации. Что такое «тип» с точки зрения проверяющей программы, разбирается в курсе Программирование с нуля; про то, что переменная в Python — это имя, привязанное к объекту, — в модели данных; про классы, ABC и dataclass — в ООП.

Аннотация — это данные, а не проверка

Первое, что нужно принять: интерпретатор не проверяет аннотации никогда.

def repeat(text: str, times: int) -> str:
    """Аннотации записаны в объект функции, но ни на что не влияют."""
    return text * times

print(repeat([1, 2], 3))          # [1, 2, 1, 2, 1, 2] — никакой ошибки
print(repeat.__annotations__)     # {'text': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}

Аннотация — это выражение, результат которого сохраняется в __annotations__ функции, класса или модуля. Всё. Никакой валидации, никакого приведения, никакого влияния на байт-код. Поэтому у аннотаций нулевая цена в горячем цикле — и поэтому же они бесполезны против «грязных» данных, пришедших из HTTP или базы: там нужна настоящая валидация.

Разделение важно на практике. Ветка «чекер» защищает разработчика; ветка «библиотека читает аннотации» защищает систему от внешних данных. Первое не заменяет второе.

Когда аннотация всё-таки выполняется

По умолчанию (до Python 3.14) выражение в аннотации вычисляется в момент определения функции. Это создаёт две проблемы: forward reference на ещё не определённый класс и циклические импорты ради типов. Классические обходные пути:

from __future__ import annotations   # PEP 563: все аннотации становятся строками

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # Этот импорт видит только чекер: в рантайме он не выполняется,
    # поэтому цикл импортов не возникает и старт приложения не тормозит.
    from app.billing.models import Invoice


def total(invoice: Invoice) -> int:      # имя Invoice в рантайме не резолвится — и не нужно
    return sum(line.amount for line in invoice.lines)

В Python 3.14 (PEP 649) аннотации стали отложенными по умолчанию: они хранятся как функция-вычислитель и считаются только при обращении к __annotations__. Это убирает большинство forward-ref-проблем без from __future__ import annotations и не превращает типы в строки — библиотеки вроде pydantic снова получают настоящие объекты. Новый модуль annotationlib даёт три формата чтения: VALUE, FORWARDREF, STRING.

Практический вывод: if TYPE_CHECKING остаётся рабочим инструментом (он экономит время импорта), а from __future__ import annotations в новом коде на 3.14+ уже не нужен.

Постепенная типизация: Any — не вершина, а выключатель

Главное недоразумение новичков: «Any — это как object, самый общий тип». Нет. object — действительно вершина иерархии: любой объект является object, но с object почти ничего нельзя сделать без явной проверки. Any — это дырка в проверке: значение типа Any совместимо с чем угодно в обе стороны.

Any против object: подтип и совместимость

from typing import Any

def handle(x: object, y: Any) -> None:
    x.upper()          # error: "object" has no attribute "upper"  ← чекер защищает
    y.upper()          # ok — но упадёт в рантайме, если y окажется числом
    n: int = y         # ok — Any «подходит» и сюда

Формально Python использует не отношение подтипа, а отношение совместимости (consistency) из теории gradual typing: Any совместим с T, а T совместим с Any для любого T. Транзитивности при этом нет — иначе всё было бы совместимо со всем. Практическое следствие: один Any на границе тихо отключает проверку во всей цепочке вызовов ниже.

import json

raw = '{"user": {"id": "не-число"}}'
data = json.loads(raw)          # тип: Any
user_id = data["user"]["id"]    # тип: Any — mypy не скажет ни слова
charge(user_id, amount=100)     # даже если charge ждёт int, ошибки не будет

Именно поэтому в проде «строгий mypy» без разбирательства с границами — иллюзия безопасности. Границы (JSON, ORM без стабов, **kwargs из конфига) должны быть закрыты валидацией, а не аннотацией. Об этом ниже, в разделе про pydantic.

Третий важный тип — Never (в старом коде NoReturn): это «нижний» тип, у которого нет ни одного значения. Функция, возвращающая Never, не возвращается вообще; переменная типа Never означает «сюда исполнение не дойдёт». Он же — основа проверки на полноту разбора вариантов, см. assert_never ниже.

Словарь типов, покрывающий 90% реального кода

Современный минимум (Python 3.10+) выглядит так:

from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from decimal import Decimal
from pathlib import Path

def load_prices(path: Path) -> dict[str, Decimal]: ...        # PEP 585: встроенные дженерики
def find(ids: Sequence[int]) -> int | None: ...               # PEP 604: X | None вместо Optional[X]
def total(rows: Iterable[tuple[str, int]]) -> int: ...         # кортеж фиксированной формы
def chunks(src: Iterable[str], n: int) -> Iterator[list[str]]: ...
def retry(fn: Callable[[], int], attempts: int = 3) -> int: ...

Устаревшее (typing.List, typing.Dict, typing.Optional, typing.Union) работает, но в новом коде не пишется: ruff-правило UP из главы про инструментарий заменит их автоматически. Абстрактные типы берём из collections.abc, а не из typing — с 3.9 они параметризуются напрямую.

Ключевое правило выбора типа — вариантность.

Ковариантность, инвариантность и контравариантность контейнеров

def average(xs: list[float]) -> float:
    return sum(xs) / len(xs)

nums: list[int] = [1, 2, 3]
average(nums)
# error: Argument 1 to "average" has incompatible type "list[int]"; expected "list[float]"

Ошибка выглядит абсурдно (ведь int подставляется вместо float где угодно), но она правильная: list изменяем, значит, внутри average можно было бы выполнить xs.append(3.14) и испортить список целых. Исправление — объявить в параметре интерфейс только на чтение:

def average(xs: Sequence[float]) -> float:     # Sequence ковариантна: list[int] подойдёт
    return sum(xs) / len(xs)
В параметрах пишите Вместо Почему
Iterable[T] list[T] достаточно одного прохода; примет генератор, множество, кортеж
Sequence[T] list[T] нужны индексы и len, но не мутация
Mapping[K, V] dict[K, V] только чтение словаря
Collection[T] list[T] нужны len и in, порядок неважен
set[T] / list[T] если функция действительно мутирует аргумент

В возвращаемом типе — наоборот: пишите конкретный тип (list[str], dict[str, int]), чтобы вызывающему было чем пользоваться. Это тот же принцип, что и «принимай интерфейс, возвращай структуру» в Go (https://courses.digitable.life/post/golang/02-fundamentals/).

Callable, ParamSpec и типизация декораторов

Наивно типизированный декоратор стирает сигнатуру функции и открывает дыру размером с приложение:

def timed(fn: Callable[..., Any]) -> Callable[..., Any]:   # плохо: возвращает Any
    ...

ParamSpec (PEP 612) переносит параметры как единое целое. С синтаксисом PEP 695 (Python 3.12+) это читается почти как на других языках:

import functools
import logging
import time
from collections.abc import Callable

def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """Сохраняет и параметры, и возвращаемый тип обёрнутой функции."""
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        started = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            logging.info("%s: %.1f мс", fn.__qualname__, (time.perf_counter() - started) * 1000)
    return wrapper

@timed
def charge(user_id: int, amount: int) -> bool: ...

charge("42", 100)     # error: Argument 1 has incompatible type "str"; expected "int" — сигнатура сохранена

Concatenate описывает декораторы, которые добавляют или съедают первый аргумент — типичный случай для «инъекции» соединения с БД:

from typing import Concatenate

def with_conn[**P, R](fn: Callable[Concatenate[Connection, P], R]) -> Callable[P, R]:
    """Снаружи функция вызывается без conn: декоратор подставит его сам."""
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        with pool.connection() as conn:
            return fn(conn, *args, **kwargs)
    return wrapper

Декораторы, замыкания и functools.wraps подробно разобраны в главе про функции; здесь важно только то, что нетипизированный декоратор — самый частый источник скрытого Any в реальных кодовых базах. Флаг disallow_untyped_decorators в strict-режиме существует именно поэтому.

Protocol: структурная типизация внутри номинального мира

Python исторически держится на «утиной типизации»: важно, что объект умеет, а не от кого он унаследован. typing.Protocol (PEP 544) даёт этому статическую форму — контракт, которому класс соответствует без наследования.

from typing import Protocol

class OrderRepository(Protocol):
    """Порт: что домену нужно от хранилища. Реализации об этом классе не знают."""
    def get(self, order_id: str) -> Order | None: ...
    def save(self, order: Order) -> None: ...


class PostgresOrderRepository:          # никакого наследования от протокола
    def __init__(self, pool: ConnectionPool) -> None:
        self._pool = pool
    def get(self, order_id: str) -> Order | None: ...
    def save(self, order: Order) -> None: ...


class InMemoryOrderRepository:          # тестовый дубль, тоже сам по себе
    def __init__(self) -> None:
        self._items: dict[str, Order] = {}
    def get(self, order_id: str) -> Order | None:
        return self._items.get(order_id)
    def save(self, order: Order) -> None:
        self._items[order.id] = order


def confirm(repo: OrderRepository, order_id: str) -> None:
    """Домен зависит от протокола — и подходят обе реализации."""
    order = repo.get(order_id)
    if order is None:
        raise KeyError(order_id)
    repo.save(order.confirmed())

Почему это лучше ABC на границах слоёв: реализация не обязана импортировать протокол. Инфраструктурный модуль не знает про домен, а домен не знает про инфраструктуру — зависимость направлена в одну сторону, и это ровно то, чего требует гексагональная архитектура. Тестовый дубль пишется в три строки без Mock.

Protocol ABC
Связь структурная, по форме номинальная, через наследование
Импорт в реализации не нужен обязателен
Проверка статическая (mypy/pyright) в рантайме, при инстанцировании
Общий код в базовом классе нет (только дефолтные методы) да
Когда брать порты, границы слоёв, чужие классы иерархия своих классов с общим кодом

Атрибуты в протоколе объявляются как поля или как property, и от этого зависит вариантность:

class HasName(Protocol):
    name: str            # изменяемый атрибут → протокол инвариантен по нему

class ReadsName(Protocol):
    @property
    def name(self) -> str: ...    # только чтение → ковариантно, подойдёт и подтип str

Осторожно с @runtime_checkable: он разрешает isinstance(), но проверяет только наличие атрибутов, а не сигнатуры, и стоит дорого (обход dir() объекта). Для issubclass() протокол с не-методными членами вообще запрещён и бросит TypeError. Используйте runtime_checkable для редких диспетчеризаций, а не в горячем пути.

Generics: от TypeVar к синтаксису PEP 695

Обобщения нужны там, где функция или контейнер сохраняет связь между типами входа и выхода. Старый синтаксис (совместим со всеми версиями):

from typing import TypeVar

T = TypeVar("T")

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

reveal_type(first([1, 2, 3]))    # Revealed type is "builtins.int" — связь сохранена

Начиная с 3.12 параметры типов объявляются прямо в сигнатуре, и это стоит предпочитать: переменная типа локальна, не «утекает» между функциями и не требует ручного объявления вариантности.

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

# Bound: T — любой подтип Order, поэтому внутри доступны его атрибуты,
# а вызывающий получит обратно ровно свой подкласс, а не Order
def newest[T: Order](items: Sequence[T]) -> T:
    return max(items, key=lambda order: order.created_at)

# Constraints: T — ровно один из перечисленных типов, смешивать нельзя
def total[T: (int, Decimal)](items: Sequence[T]) -> T:
    return sum(items[1:], start=items[0])

class Repository[K, V]:
    """Обобщённый контейнер: вариантность выводится автоматически."""
    def __init__(self) -> None:
        self._items: dict[K, V] = {}
    def add(self, key: K, value: V) -> None:
        self._items[key] = value
    def get(self, key: K) -> V | None:
        return self._items.get(key)

repo = Repository[str, Order]()

Разница между T: Comparable (bound — любой подтип) и T: (int, str) (constraints — ровно один из перечисленных, без «общего надтипа») регулярно путается. Bound нужен почти всегда; constraints — редкий инструмент для функций вроде max, где смешивать типы нельзя.

Ещё три конструкции, без которых типизация библиотек не живёт:

from typing import Self

class QueryBuilder:
    def where(self, cond: str) -> Self:      # PEP 673: наследник вернёт свой тип, а не QueryBuilder
        self._conds.append(cond)
        return self

# PEP 695: псевдоним типа вычисляется лениво, поэтому рекурсия работает
type Json = str | int | float | bool | None | list[Json] | dict[str, Json]

# PEP 696: значение по умолчанию для параметра типа
class Response[T = dict[str, Json]]: ...

Сужение типов: как чекер рассуждает о ветвлениях

Проверяющая программа моделирует поток управления. Внутри if она знает о переменной больше, чем снаружи, — это и есть narrowing.

def describe(v: int | str | None) -> str:
    if v is None:
        return "пусто"                 # здесь v: None
    if isinstance(v, int):
        return f"число {v + 1}"        # здесь v: int
    return v.upper()                   # здесь v: str — union исчерпан

Работают: is None / is not None, isinstance, type(x) is C, сравнение с Literal-значениями, проверка assert, in-проверка по литеральному кортежу, ключи TypedDict, «моржовый» оператор. Не работают: проверки через вспомогательную функцию (if valid(v): ...) — именно для них существуют TypeGuard и TypeIs.

from typing import TypeGuard, TypeIs

def all_strings(vals: list[object]) -> TypeGuard[list[str]]:
    """TypeGuard: сужает ТОЛЬКО в положительной ветке; целевой тип может быть любым."""
    return all(isinstance(v, str) for v in vals)

def is_int(v: object) -> TypeIs[int]:
    """TypeIs (PEP 742): сужает в обеих ветках, но требует совместимости с входным типом."""
    return isinstance(v, int)

def handle(v: int | str) -> None:
    if is_int(v):
        print(v + 1)        # v: int
    else:
        print(v.upper())    # v: str — TypeGuard так не умеет, TypeIs умеет

Правило выбора простое: если проверяемый тип — подтип аргумента, берите TypeIs (он умнее); если функция «переинтерпретирует» значение в несовместимый тип (как list[object] → list[str] при инвариантном list), остаётся TypeGuard.

Проверка на полноту разбора: assert_never

Самая ценная для продакшена техника: сборка ломается, как только вы добавили новый вариант в перечисление и забыли обработать его хотя бы в одном месте:

from enum import Enum
from typing import assert_never

class Status(Enum):
    NEW = "new"
    PAID = "paid"
    SHIPPED = "shipped"

def label(status: Status) -> str:
    match status:
        case Status.NEW:
            return "новый"
        case Status.PAID:
            return "оплачен"
        case Status.SHIPPED:
            return "отправлен"
        case _ as unreachable:
            assert_never(unreachable)     # тип здесь — Never, ветка недостижима

Добавьте Status.CANCELLED — и mypy сообщит: Argument 1 to "assert_never" has incompatible type "Literal[Status.CANCELLED]"; expected "Never", указав каждое место, где разбор перестал быть полным. Это статический аналог исчерпывающего match из Rust и Elixir, и ради одного этого стоит вводить типы в доменном ядре.

Точные типы для точных контрактов

Literal превращает строковый флаг в перечисление без класса:

from typing import Literal

Mode = Literal["read", "write", "append"]

def open_file(path: Path, mode: Mode = "read") -> IO[str]: ...
open_file(p, "wrigt")   # error: Argument 2 has incompatible type "Literal['wrigt']"

overload описывает функцию, чей возвращаемый тип зависит от аргументов:

from typing import overload

@overload
def parse(raw: str, *, strict: Literal[True]) -> Config: ...
@overload
def parse(raw: str, *, strict: Literal[False] = False) -> Config | None: ...

def parse(raw: str, *, strict: bool = False) -> Config | None:
    """Единственная реальная реализация; перегрузки видит только чекер."""
    try:
        return Config(**json.loads(raw))
    except (ValueError, TypeError):
        if strict:
            raise
        return None

cfg = parse(raw, strict=True)     # тип: Config, без Optional-плясок на стороне вызова

TypedDict описывает форму словаря — незаменим для JSON-ответов чужих API:

from typing import NotRequired, TypedDict

class WebhookEvent(TypedDict):
    id: str
    type: str
    created_at: int
    payload: NotRequired[dict[str, str]]     # ключ может отсутствовать

def handle(event: WebhookEvent) -> None:
    if event["typ"]:      # error: TypedDict "WebhookEvent" has no key "typ"
        ...

Ловушка: TypedDict не валидирует ничего в рантайме. cast(WebhookEvent, json.loads(body)) — это обещание чекеру, а не проверка. Если данные пришли извне, валидируйте их.

NewType создаёт различимые типы поверх примитивов ценой нуля байт в рантайме:

from typing import NewType

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

def load_user(uid: UserId) -> User: ...

load_user(OrderId(17))   # error: expected "UserId" — перепутанные id ловятся статически
print(UserId(17) + 1)    # 18 — в рантайме это по-прежнему обычный int

Final, ClassVar, @override фиксируют намерения:

from typing import ClassVar, Final, override

MAX_RETRIES: Final = 5          # переприсваивание — ошибка чекера

class Job:
    registry: ClassVar[dict[str, type]] = {}   # атрибут класса, а не экземпляра
    @override                                   # PEP 698: опечатка в имени метода станет ошибкой
    def run(self) -> None: ...

Annotated прикрепляет к типу метаданные, которые читают библиотеки:

from typing import Annotated
from pydantic import Field

Positive = Annotated[int, Field(gt=0)]        # для чекера это просто int

mypy в реальном проекте

Конфигурация живёт в pyproject.toml рядом с ruff и pytest (см. инструментарий):

[tool.mypy]
python_version = "3.12"
strict = true                 # включает пакет флагов, разобранный ниже
warn_unreachable = true       # в strict НЕ входит, но ловит настоящие баги
extra_checks = true
pretty = true
plugins = ["pydantic.mypy"]

# Легаси-модуль: проверяем тела, но не требуем аннотаций. Список должен только сокращаться.
[[tool.mypy.overrides]]
module = ["legacy.reports.*", "legacy.imports.*"]
disallow_untyped_defs = false
disallow_untyped_calls = false

# Библиотека без стабов: единственное место, где допустим ignore_missing_imports
[[tool.mypy.overrides]]
module = ["ancient_soap_client.*"]
ignore_missing_imports = true

Что именно включает --strict:

Флаг Что ловит
disallow_untyped_defs функции вообще без аннотаций
disallow_incomplete_defs аннотирована часть параметров
check_untyped_defs тела неаннотированных функций (иначе они не проверяются вовсе)
disallow_untyped_calls вызов нетипизированной функции из типизированной
disallow_untyped_decorators декоратор, стирающий сигнатуру
disallow_any_generics голый list вместо list[str]
disallow_subclassing_any наследование от класса, который для mypy — Any
warn_return_any возврат Any из функции с конкретным типом
warn_redundant_casts бесполезный cast
warn_unused_ignores протухший # type: ignore
no_implicit_reexport импорт «транзитом» через чужой модуль
strict_equality сравнение заведомо несравнимых типов (b"x" == "x")
extra_checks доп. проверки TypedDict, ParamSpec, изменяемых полей

Чего strict не включает: warn_unreachable (мёртвый код), disallow_any_explicit (запрет писать Any руками), disallow_any_expr (практически неприменим), disallow_any_unimported (Any из непроверяемых импортов). Первые два стоит включить осознанно; последний очень полезен, если вы боретесь с протечками Any.

Как подавлять ошибки правильно

value = legacy_api.fetch()  # type: ignore[no-any-return]  # TODO(PAY-1421): убрать после типизации legacy_api

Всегда указывайте код ошибки в скобках и причину. Голый # type: ignore глушит всё, включая ошибки, которые появятся завтра. Флаг warn_unused_ignores (в strict) заставит убрать подавление, когда оно перестанет быть нужным, — это и есть механизм самоочистки.

cast — вторая форма подавления: он ничего не проверяет и не стоит ничего в рантайме. Если можно проверить по-настоящему, используйте assert isinstance(...) или валидатор. cast уместен там, где вы знаете то, чего чекер знать не может (динамическая загрузка плагина, десериализация после явной проверки схемы).

Стабы, py.typed и чужие библиотеки

Типы для стандартной библиотеки и сотен пакетов лежат в typeshed. Для пакетов вроде requests ставится отдельный дистрибутив стабов (types-requests), команда mypy --install-types подскажет нужные. Свою библиотеку помечайте маркером PEP 561 — пустым файлом py.typed внутри пакета, иначе потребитель получит от вас сплошной Any:

src/orderflow/
├── __init__.py
├── py.typed          # ← без него ваши аннотации не видны снаружи
└── domain.py

Файл нужно не забыть включить в сборку (для hatchling — [tool.hatch.build] include), подробности — в главе про пакетирование. Если типы вынесены в .pyi, проверяйте их соответствие реализации утилитой stubtest из состава mypy — расхождение стаба и кода хуже отсутствия стаба.

Главная ловушка: ignore_missing_imports = true глобально. Она не «отключает предупреждение», она превращает всё содержимое ненайденного модуля в Any — и strict становится декорацией. Правильный порядок действий: искать стабы → писать минимальный собственный стаб в stubs/ (mypy_path = "stubs") → и только потом точечный override на конкретный модуль.

Скорость

mypy кеширует результаты инкрементально (.mypy_cache). На кодовой базе в 200–300 тысяч строк холодный прогон занимает минуты, инкрементальный — секунды. Локально используйте демон: dmypy run -- src держит состояние в памяти и отвечает за доли секунды. В CI кешируйте .mypy_cache между запусками — это обычно превращает трёхминутный шаг в двадцатисекундный.

Внедрение в код, который писался без типов

Порядок, который работает на практике:

  1. Включить mypy без strict на весь проект и зафиксировать текущее число ошибок. Цель первого шага — не «починить», а перестать ухудшать.
  2. Поставить ратчет (храповик) в CI: список исключений в overrides и число ошибок могут только сокращаться — код с регрессом типизации не мержится.
  3. Типизировать снизу вверх: сначала листья (утилиты, доменные модели, чистые функции), потом слои над ними. Если начать сверху, Any из нетипизированных зависимостей будет протекать в свежий код и обесценивать работу.
  4. Новые модули — сразу строгие. В overrides попадает только унаследованный код.
  5. Измерять, а не верить: mypy --any-exprs-report report/ src даёт долю выражений типа Any по модулям. Это честная метрика прогресса, в отличие от «мы всё аннотировали».
  6. Границы закрыть валидацией до того, как объявлять победу.

Простейший ратчет — десять строк в пайплайне:

uv run mypy src | tee mypy.log
errors=$(grep -c ": error:" mypy.log || true)
baseline=$(cat mypy-baseline.txt)          # число, закоммиченное в репозиторий
if [ "$errors" -gt "$baseline" ]; then
  echo "Регресс типизации: было $baseline, стало $errors"
  exit 1
fi

Готовый инструмент для того же — mypy-baseline: он хранит подробный снимок известных ошибок и показывает в diff только новые, поэтому не даёт «обменять» исправление одной ошибки на появление другой.

Реальные истории внедрения полезно прочитать целиком: Dropbox — типизация 4 миллионов строк и Zulip о раннем mypy. Оба доклада сходятся в одном: ценность появляется не от «100% покрытия», а от строгости на границах модулей.

mypy, pyright и новое поколение чекеров

Инструмент Особенности Когда выбирать
mypy эталонная реализация, плагины (pydantic, django-stubs, SQLAlchemy), самый предсказуемый основной проверяющий в CI
pyright на TypeScript/Node, очень быстрый, самое умное сужение типов, движок Pylance в VS Code в редакторе; в CI как второй чекер для строгих модулей
ty (Astral) на Rust, на порядок быстрее, интеграция с uv/ruff пробовать локально; на 2026 год ещё не 1.0
pyrefly (Meta) на Rust, наследник Pyre, ориентирован на огромные монорепо эксперименты на больших базах

Важное следствие плюрализма: чекеры расходятся в трактовке пограничных случаев. Единый источник истины — Python Typing Specification и conformance-тесты к ней. Практическая рекомендация: держать один чекер как gate в CI, остальные — как советчиков, иначе получите бесконечный спор двух инструментов в pull request.

Плагины стоит упомянуть отдельно: Django, SQLAlchemy и pydantic активно используют метапрограммирование, и без django-stubs / sqlalchemy[mypy] / pydantic.mypy чекер там видит Any. Пакет плагинов привязан к версии mypy — это регулярный источник болезненных апгрейдов.

Где типы кончаются и начинается валидация

Аннотация описывает намерение; данные из внешнего мира намерению не подчиняются. Граница проходит по вводу-выводу:

from pydantic import BaseModel, TypeAdapter, ValidationError

class WebhookIn(BaseModel):
    id: str
    type: Literal["payment.succeeded", "payment.failed"]
    amount: int

adapter = TypeAdapter(list[WebhookIn])

def handle_batch(body: bytes) -> list[WebhookIn]:
    try:
        return adapter.validate_json(body)     # реальная проверка в рантайме
    except ValidationError as exc:
        raise BadRequest(exc.errors()) from exc

Внутри приложения после этой точки типы можно считать правдой, и там достаточно dataclass — pydantic-модели не обязаны протекать в домен. Такое разделение («валидируем на границе, внутри доверяем») подробно разбирается в главе про веб и API и в продакшн-архитектуре.

Альтернатива для точечных случаев — рантайм-проверка по тем же аннотациям: beartype (проверка за O(1) — сэмплирует элементы коллекций) и typeguard. Их разумно включать в тестовом окружении, а не в проде.

Грабли, которые ловят на ревью

  1. Dict[str, Any] как универсальный тип данных. Формально типизировано, практически — нет. Замена: TypedDict, dataclass или pydantic-модель.
  2. ignore_missing_imports = true глобально. Тихо превращает strict в фикцию.
  3. Голый # type: ignore. Заглушает и сегодняшнюю ошибку, и завтрашнюю. Всегда с кодом: # type: ignore[arg-type].
  4. list[float] в параметре. Инвариантность отвергнет list[int]. Пишите Sequence[float].
  5. Optional там, где нужен явный «не задано». def f(x: int | None = None) часто маскирует три разных состояния (нет значения / значение пустое / значение нулевое). Иногда честнее Literal["unset"] или отдельный sentinel-объект.
  6. Изменяемое значение по умолчанию с аннотацией. def f(xs: list[int] = []) — аннотация не спасает от классической ловушки общего списка (см. функции); ruff-правило B006 ловит это.
  7. cast вместо проверки. Каждый cast — необеспеченное обещание. Их количество стоит считать в code review.
  8. Аннотация -> None у генератора. Функция с yield возвращает Iterator[T] (или Generator[Y, S, R]), и mypy об этом скажет — но только если тело проверяется.
  9. Any из ORM/DataFrame. df["col"] для pandas — почти всегда Any. Строгость в data-коде даёт мало (подробности — в работе с данными).
  10. Протокол вместо ABC там, где нужен общий код. Протокол не переиспользует реализацию; если у наследников есть общее поведение — берите базовый класс.
  11. Циклический импорт ради типа. Лечится if TYPE_CHECKING, а не переносом кода «чтобы компилировалось».
  12. runtime_checkable в горячем цикле. isinstance по протоколу дороже обычного на порядок и всё равно проверяет только имена атрибутов.

Где строгая типизация окупается, а где мешает

Честно про ограничения. Типизация Python несостоятельна (unsound) по построению: gradual typing допускает Any, а cast разрешает соврать. Это не баг, а плата за совместимость с двадцатью годами динамического кода. Гарантий уровня Rust или Haskell здесь не будет никогда, и обещать их команде — плохая идея.

Типы почти не помогают там, где сила Python как раз в динамике: Django ORM с его Model.objects.filter(**kwargs), конфигурируемые фабрики, плагинные системы, DataFrame с колонками, известными только в рантайме, метаклассы. В таких местах строгость превращается в поток cast и # type: ignore — то есть в шум.

Зато выигрыш огромен на рефакторинге. Переименовать поле в доменной модели, изменить контракт репозитория, поднять версию библиотеки — во всех этих операциях чекер за секунды находит места, которые тесты не покрывают. Плюс IDE: автодополнение и переходы по коду работают на аннотациях, а не на догадках.

Чек-лист перед мержем

  • В сигнатурах публичных функций нет Any — ни явного, ни протёкшего.
  • Параметры — Iterable/Sequence/Mapping, возвращаемые значения — конкретные типы.
  • Границы (HTTP, очередь, БД, файлы) валидируются, а не приводятся cast.
  • Каждый # type: ignore — с кодом ошибки и ссылкой на тикет.
  • Разбор Enum и размеченных объединений закрыт assert_never.
  • Порты между слоями описаны Protocol, а не конкретными классами.
  • Декораторы типизированы через ParamSpec, а не Callable[..., Any].
  • В библиотеке лежит py.typed, и он попадает в собранный дистрибутив.
  • ignore_missing_imports — только точечно, в overrides на конкретный модуль.
  • mypy запущен в CI с закешированным .mypy_cache и с ратчетом на число ошибок.

Мини-итог

Аннотации в Python — данные, а не проверка: интерпретатор их игнорирует, а работу выполняет отдельный чекер. Система типов постепенная, и её центральный элемент — Any: не вершина иерархии, а выключатель проверок, который тихо распространяется по коду; настоящая вершина — object. Три конструкции покрывают большую часть реальных задач: Protocol для контрактов между слоями, обобщения из PEP 695 для переиспользуемого кода, assert_never для полноты разбора вариантов. Вариантность диктует практическое правило «принимай Sequence, возвращай list». В существующий проект строгость вводится ратчетом снизу вверх, а границы с внешним миром закрываются валидацией (pydantic), потому что аннотация не проверяет ничего. Наконец, честность: типы в Python несостоятельны по построению и мало помогают в динамическом метапрограммировании — зато радикально удешевляют рефакторинг там, где код живёт годами.

Источники

Что дальше

Исключения и обработка ошибок: иерархия, свои исключения, EAFP — разберём вторую половину контракта функции: что она обещает не только по типам, но и по отказам. Иерархия встроенных исключений, свои классы ошибок, почему в Python принят стиль «проще попросить прощения, чем разрешения», и как не превратить except Exception в чёрную дыру для багов.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов