Тайп-хинты как система: объединения, TypedDict, Literal и типизация границ в первую очередь
Аннотации в Python стираются в рантайме — их проверяет только чекер. Объединения X | Y, дженерики коллекций, TypedDict, Literal и Annotated превращают словари и строковые флаги в контракты. В легаси типизируйте сначала границы: проверенный хинт — документация, которая не врёт.
Сигнатура гласила def load_user(user_id: str) -> User: — и гласила так три года. Новый инженер, только что с TypeScript-кодовой базы, прочитал хинт, поверил ему и написал в биллинговой джобе цепочку load_user(uid).subscription.plan. В 02:40 сработал пейджер: AttributeError: 'NoneType' object has no attribute 'subscription', четыре тысячи инвойсов зависли. Функция всегда возвращала None для мягко удалённых аккаунтов — каждый ветеран команды «просто знал» это, — но аннотация ни разу в этом не призналась, потому что ничто не заставляло её признаться. Команда внедрила тайп-хинты двумя годами раньше «как документацию». Чекер так и не подключили, и документация дрейфовала ровно так же, как дрейфуют комментарии: молча, по одному рефакторингу за раз. Фикс из постмортема занял одиннадцать символов — -> User | None — плюс одна CI-джоба, которая отклонила бы это враньё в день его появления. Хинты, которые никто не проверяет, — это комментарии с синтаксисом получше.
Что рантайм на самом деле делает с хинтом: ничего
Именно это различие и объясняет, почему баг из Hook три года оставался незамеченным. Как только вы поймёте, что рантайм с аннотацией делает — а чего не делает — стратегия «типизируйте сначала границы» перестанет быть советом и станет очевидностью.
CPython вычисляет аннотации и складывает их в словарь __annotations__ (встроенный словарь аннотаций функции) — на этом всё. def f(x: int) -> str спокойно примет список и вернёт None; никакой проверки, никакого приведения, никакого предупреждения и практически нулевая цена на вызов. Это сознательное решение (PEP 484 не зря называет типы «хинтами»): язык остаётся динамическим, а контроль делегирован внешним инструментам — mypy, pyright, вашей IDE. Следствие режет в обе стороны. Ничего не ломается, если хинты неверны, — поэтому непроверяемые хинты гниют во враньё вроде -> User из Хука. Но поскольку аннотации — это интроспектируемые данные, рантайм-фреймворки читают их намеренно: pydantic строит из них валидаторы, FastAPI выводит парсинг запросов и OpenAPI-схемы, dataclasses — поля. Один синтаксис, два потребителя — статические чекеры, которые хинтам доверяют, и рантайм-библиотеки, которые их исполняют, — и путать их между собой — классическая ловушка новичка: запись x: int валидирует x не больше, чем комментарий.
Словарь системы: объединения, дженерики, TypedDict, Literal, Annotated
Прежде чем смотреть на синтаксис: прикиньте, сколько анонимных словарей гуляет по вашему сервису без имён и без проверенных форм. Каждый — контракт, живущий только в чьей-то голове. Всё, что описано ниже, существует ровно затем, чтобы эти контракты записать и отдать на проверку чекеру.
Современный синтаксис хинтов (Python 3.10+) достаточно компактен, чтобы использовать его повсюду. X | Y (PEP 604) заменяет Optional[X] и Union[X, Y]; встроенные коллекции параметризуются напрямую — list[int], dict[str, Decimal] — без импортов typing.List. Рабочие лошадки реальных кодовых баз — типы формы:
from typing import Annotated, Literal, TypedDict
class Charge(TypedDict):
amount_cents: int
currency: Literal["usd", "eur"] # only these two strings type-check
customer_id: str
class ChargeWithMeta(Charge, total=False):
idempotency_key: str # optional key: may be absent
UserId = Annotated[str, "uuid4 from users.id"] # extra metadata, same type
def settle(charge: Charge, retries: list[int] | None = None) -> bool:
if charge["currency"] == "eur": # checker knows the literal set
...
return TrueTypedDict — самый недооценённый инструмент в легаси: он даёт имя и проверяемую форму анонимным словарям, которые текут через любой Python-сервис, не меняя ни байта в рантайме — Charge остаётся обычным dict, так что ни один вызов не ломается. Literal превращает строковые флаги-режимы в замкнутые множества: settle(..., currency="usdd") становится падением сборки, а не KeyError в чужом леджере в три часа ночи. Annotated прикрепляет метаданные (единицы измерения, правила валидации), которые потребляют pydantic и FastAPI, тогда как чекеры видят базовый тип. Честная оговорка: TypedDict проверяет ключи и типы значений, но по умолчанию не лишние ключи, и не спасёт от словаря, собранного руками с опечаткой в непроверяемой точке вызова — покрытие ровно настолько хорошо, насколько большую часть графа вызовов видит чекер.
Функция аннотирована def f(x: int) -> str. В рантайме вызывающий код передаёт список. Что происходит в момент самого вызова?
Постепенная типизация легаси: сначала границы
Нетипизированный сервис на 100 тысяч строк нельзя типизировать одним героическим спринтом — попытка породит PR «перелопатить всё», который никто не отревьюит. Работающая стратегия — постепенная типизация от границ внутрь. Сначала типизируйте края: HTTP-хендлеры, функции доступа к БД, консьюмеры очередей, публичные функции каждого пакета — места, где данные входят и выходят, потому что именно там неверная форма наносит максимальный урон и именно там TypedDict или dataclass документируют контракт, от которого зависят другие команды. Внутренние хелперы поначалу могут оставаться без аннотаций: чекер считает неаннотированные функции Any и пропускает их, так что типизированная поверхность растёт без ложных тревог. Дальше — храповик: фиксируйте строгость помодульно в конфиге (секции per-module у mypy, execution environments у pyright), чтобы уже типизированные модули не могли регрессировать, и требуйте аннотации во всём новом коде. Команды, идущие этим путём, видят отдачу быстро — чекер начинает ловить настоящие баги (забытый None, перепутанный порядок аргументов, опечатки в ключах словаря) уже на первых пограничных модулях, задолго до внушительного покрытия. Антипаттерн — посыпать хинтами внутренности, пока I/O-край всё ещё гоняет сырые словари: получите шум аннотаций без ценности контракта.
У вас неделя на внедрение типизации в нетипизированный Django-сервис на 100 тысяч строк. Где хинты дают больше всего безопасности на час работы?
- 01Что рантайм Python делает с тайп-хинтами и какие два вида потребителей придают хинтам ценность?
- 02Вам достался большой нетипизированный код. Опишите стратегию постепенной типизации и почему TypedDict в ней центральный инструмент.
Типизация в Python — это контрактный слой, который рантайм сознательно игнорирует: аннотации вычисляются в __annotations__ и никогда не проверяются, так что def f(x: int) -> str без звука примет список. Контроль принадлежит потребителям — статическим чекерам вроде mypy и pyright, проверяющим код до запуска, и рантайм-библиотекам вроде pydantic и FastAPI, которые интроспектируют хинты ради валидаторов и схем. Современный словарь делает контракты дешёвыми: объединения X | Y из PEP 604, параметризованные встроенные типы вроде dict[str, Decimal], TypedDict, дающий анонимным словарям именованную проверяемую форму при том, что в рантайме это обычные dict, Literal, замыкающий множества строковых флагов, и Annotated, несущий метаданные для рантайм-инструментов. Режим отказа — непроверяемый хинт: документация, дрейфующая молча, как -> User, три года возвращавший None, пока доверчивый читатель не отправил AttributeError в прод. Стратегия для легаси — постепенная типизация от границ внутрь: сначала хендлеры, доступ к БД, консьюмеры и публичные API, потому что именно там болят ошибки формы; чекер считает неаннотированное Any, а помодульный храповик строгости не даёт типизированным модулям регрессировать. Хинты окупаются, только когда их кто-то проверяет; подключённые к чекеру, они — единственная документация, которая валит сборку, а не устаревает. Теперь, когда в PR встретите неаннотированную граничную функцию, вы знаете, что делать: поставить настоящую форму, подключить чекер к CI и сделать невозможным повторное вранье.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.