Аннотации типов: 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 или базы: там нужна настоящая валидация.
с аннотациями"] --> CHK["Проверка типов:
mypy / pyright"] CHK -->|"ошибок нет"| CI["CI: merge разрешён"] CHK -->|"есть ошибки"| DEV["Правки в коде"] DEV --> CHK SRC --> RT["CPython: компиляция в байт-код"] RT --> ANN["Аннотации сохранены
как обычные данные"] ANN --> NOOP["Прикладной код:
эффекта ноль"] ANN --> LIB["Библиотека читает их сама:
pydantic, FastAPI, dataclasses, typer, cattrs"] LIB --> VAL["Вот здесь появляется
реальная проверка в рантайме"]
Разделение важно на практике. Ветка «чекер» защищает разработчика; ветка «библиотека читает аннотации» защищает систему от внешних данных. Первое не заменяет второе.
Когда аннотация всё-таки выполняется
По умолчанию (до 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 совместимо с чем угодно в обе стороны.
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 между запусками — это обычно превращает трёхминутный шаг в
двадцатисекундный.
Внедрение в код, который писался без типов
Порядок, который работает на практике:
- Включить mypy без strict на весь проект и зафиксировать текущее число ошибок. Цель первого шага — не «починить», а перестать ухудшать.
- Поставить ратчет (храповик) в CI: список исключений в
overridesи число ошибок могут только сокращаться — код с регрессом типизации не мержится. - Типизировать снизу вверх: сначала листья (утилиты, доменные модели, чистые
функции), потом слои над ними. Если начать сверху,
Anyиз нетипизированных зависимостей будет протекать в свежий код и обесценивать работу. - Новые модули — сразу строгие. В
overridesпопадает только унаследованный код. - Измерять, а не верить:
mypy --any-exprs-report report/ srcдаёт долю выражений типаAnyпо модулям. Это честная метрика прогресса, в отличие от «мы всё аннотировали». - Границы закрыть валидацией до того, как объявлять победу.
Простейший ратчет — десять строк в пайплайне:
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. Их разумно включать в тестовом окружении, а не в проде.
Грабли, которые ловят на ревью
Dict[str, Any]как универсальный тип данных. Формально типизировано, практически — нет. Замена:TypedDict,dataclassили pydantic-модель.ignore_missing_imports = trueглобально. Тихо превращает strict в фикцию.- Голый
# type: ignore. Заглушает и сегодняшнюю ошибку, и завтрашнюю. Всегда с кодом:# type: ignore[arg-type]. list[float]в параметре. Инвариантность отвергнетlist[int]. ПишитеSequence[float].Optionalтам, где нужен явный «не задано».def f(x: int | None = None)часто маскирует три разных состояния (нет значения / значение пустое / значение нулевое). Иногда честнееLiteral["unset"]или отдельный sentinel-объект.- Изменяемое значение по умолчанию с аннотацией.
def f(xs: list[int] = [])— аннотация не спасает от классической ловушки общего списка (см. функции); ruff-правилоB006ловит это. castвместо проверки. Каждыйcast— необеспеченное обещание. Их количество стоит считать в code review.- Аннотация
-> Noneу генератора. Функция сyieldвозвращаетIterator[T](илиGenerator[Y, S, R]), и mypy об этом скажет — но только если тело проверяется. Anyиз ORM/DataFrame.df["col"]для pandas — почти всегдаAny. Строгость в data-коде даёт мало (подробности — в работе с данными).- Протокол вместо ABC там, где нужен общий код. Протокол не переиспользует реализацию; если у наследников есть общее поведение — берите базовый класс.
- Циклический импорт ради типа. Лечится
if TYPE_CHECKING, а не переносом кода «чтобы компилировалось». 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 несостоятельны по построению и мало помогают в динамическом
метапрограммировании — зато радикально удешевляют рефакторинг там, где код живёт годами.
Источники
- typing — Support for type hints — справочник по всем конструкциям с указанием версий.
- Python Typing Specification — нормативный документ, по которому сверяются mypy, pyright и остальные.
- Static Typing with Python — руководства, FAQ и список инструментов.
- PEP 484 (базовые хинты), PEP 544 (Protocol), PEP 585 и PEP 604 (современный синтаксис), PEP 612 (ParamSpec), PEP 673 (Self), PEP 695 (параметры типов), PEP 742 (TypeIs), PEP 649 (отложенные аннотации).
- mypy documentation — в первую очередь Using mypy with an existing codebase и Cheat sheet.
- pyright configuration — строгие режимы и отличия от mypy.
- typeshed — стабы для stdlib и сторонних пакетов.
- Patrick Viafore, Robust Python — книга целиком про типизацию и её применение в больших системах.
- Luciano Ramalho, Fluent Python, 2nd ed. — главы 8, 13 и 15 о хинтах, протоколах и обобщениях.
- Jeremy Siek, Walid Taha, Gradual Typing for Functional Languages — первоисточник понятия «совместимость типов».
- Dropbox: Our journey to type checking 4 million lines of Python — практика внедрения на большом масштабе.
- pydantic — валидация по аннотациям на границах.
Что дальше
Исключения и обработка ошибок: иерархия, свои исключения, EAFP —
разберём вторую половину контракта функции: что она обещает не только по типам, но и по
отказам. Иерархия встроенных исключений, свои классы ошибок, почему в Python принят
стиль «проще попросить прощения, чем разрешения», и как не превратить except Exception
в чёрную дыру для багов.