Я читал статьи о типизации в Python и разбирал репозитории, где ею пользуются всерьёз, а не для галочки. Почти везде синтаксис не новее 3.11: Optional, TypeVar на уровне модуля, Generic[T], кавычки вокруг опережающих ссылок. При этом у Python 3.10 поддержка заканчивается в октябре 2026 года [1], и часть таких проектов формально держит его нижней границей до сих пор.
За последние несколько релизов в typing добавилось много нового: в 3.11 — Self и Required/NotRequired, в 3.12 — свой синтаксис для generic-типов и алиасов, в 3.13 — TypeIs и значения по умолчанию для параметров типа, в 3.14 — аннотации без кавычек. Дальше расскажу, как этим пользоваться на практике, где у нового есть подводные камни и что делать, если проект пока не может перейти на новую версию Python.
Все примеры запускались на CPython 3.10.20, 3.11.15, 3.12.13, 3.13.14 и 3.14.2. Статические проверки делал mypy 2.3.1, ty 0.0.78 и pyright 1.1.414, линтером был Ruff 0.15.22. Термины: «рантайм» это то, что делает интерпретатор, «checker» это статический анализатор вроде mypy. Многие возможности существуют только для checker’а, интерпретатор их не проверяет, и я буду на это указывать.
Главное: что стало проще
Сводка по версиям, подробности ниже.
Что |
Как было |
Как стало |
С какой версии |
|---|---|---|---|
Метод возвращает свой класс |
|
|
3.11 |
Часть ключей |
|
|
3.11 |
Generic-класс и функция |
|
|
3.12 |
Алиас типа |
|
|
3.12 |
Вариантность |
|
выводится автоматически |
3.12 |
Проверка переопределения |
нет |
|
3.12 |
Значение по умолчанию у параметра типа |
|
|
3.13 |
Сужение типа |
|
|
3.13 |
Ключи |
нет |
|
3.13 |
Опережающие ссылки |
кавычки или |
без кавычек |
3.14 |
Self вместо TypeVar(bound=…) (3.11)
Метод, который должен возвращать экземпляр своего класса, а не базового, раньше требовал TypeVar, привязанного к этому классу через bound:
from typing import TypeVar TAnimal = TypeVar("TAnimal", bound="Animal") class Animal: @classmethod def create(cls: type[TAnimal]) -> TAnimal: return cls()
С 3.11 (PEP 673 [8]) для этого есть Self, без отдельной переменной на каждый класс:
from typing import Self, reveal_type class Animal: @classmethod def create(cls) -> Self: return cls() def clone(self) -> Self: return type(self)() class Dog(Animal): pass reveal_type(Dog.create()) reveal_type(Dog().clone())
Оба варианта выводят подкласс, а не Animal, но Self работает и в обычных методах (clone), и не нужно объявлять TypeVar заново в каждом классе с тем же паттерном — альтернативный конструктор, clone, __enter__, возвращающий self:
Вывод mypy, ty, pyright
$ mypy example.py example.py:14: note: Revealed type is "example.Dog" example.py:15: note: Revealed type is "example.Dog" Success: no issues found in 1 source file $ ty check example.py info[revealed-type]: Revealed type --> example.py:14:13 | 14 | reveal_type(Dog.create()) | ^^^^^^^^^^^^ `Dog` info[revealed-type]: Revealed type --> example.py:15:13 | 15 | reveal_type(Dog().clone()) | ^^^^^^^^^^^^^ `Dog` Found 2 diagnostics $ pyright example.py example.py:14:13 - information: Type of "Dog.create()" is "Dog" example.py:15:13 - information: Type of "Dog().clone()" is "Dog" 0 errors, 0 warnings, 2 informations
Required и NotRequired (3.11)
До 3.11 сделать часть ключей TypedDict необязательными означало заводить два класса и наследование:
from typing import TypedDict class _MovieBase(TypedDict): title: str class Movie(_MovieBase, total=False): year: int
С 3.11 (PEP 655 [9]) то же самое пишется в одном классе:
from typing import NotRequired, TypedDict class Movie(TypedDict): title: str year: NotRequired[int] def f(m: Movie) -> None: m["title"] f({})
year теперь необязателен, а title остался обязательным ключом; f({}) вызывает f с пустым словарём, где title нет, и именно на это указывают все три инструмента:
Вывод mypy, ty, pyright
$ mypy example.py example.py:10: error: Missing key "title" for TypedDict "Movie" [typeddict-item] Found 1 error in 1 file (checked 1 source file) $ ty check example.py error[invalid-argument-type]: Argument to function `f` is incorrect --> example.py:10:3 | 10 | f({}) | ^^ Expected `Movie`, found `dict[Unknown, Unknown]` info: Function defined here --> example.py:7:5 | 7 | def f(m: Movie) -> None: | ^ -------- Parameter declared here error[missing-typed-dict-key]: Missing required key 'title' in TypedDict `Movie` constructor --> example.py:10:3 | 10 | f({}) | ^^ Found 2 diagnostics $ pyright example.py example.py:10:3 - error: Argument of type "dict[Any, Any]" cannot be assigned to parameter "m" of type "Movie" in function "f" "title" is required in "Movie" (reportArgumentType) 1 error, 0 warnings, 0 informations
Generic без TypeVar (3.12)
До 3.12 параметр типа объявляли отдельной переменной на уровне модуля и добавляли Generic в базы:
from typing import Generic, TypeVar T = TypeVar("T") class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop() def first(items: list[T]) -> T: return items[0]
С 3.12 (PEP 695 [2]) параметр указывается прямо в заголовке:
class Stack[T]: def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop() def first[T](items: list[T]) -> T: return items[0]
Имя T живёт только внутри класса или функции, в глобальное пространство имён оно не попадает. Одно и то же T больше не переиспользуется между несвязанными классами, что раньше было источником путаницы.
И bound, и constraints записываются так же коротко: def f[T: Hashable](x: T), def g[T: (int, str)](x: T). Для ParamSpec и TypeVarTuple используются **P и *Ts. Сами TypeVarTuple и Unpack появились раньше, в 3.11 (PEP 646 [10]) — до 3.12 вариативный generic объявлялся как Generic[*Ts], новый синтаксис лишь сократил объявление в заголовке класса.
Оператор type (3.12)
Алиасы раньше объявлялись через TypeAlias, и рекурсивный алиас приходилось писать строкой:
from typing import TypeAlias Json: TypeAlias = "dict[str, Json] | list[Json] | str | int | float | bool | None"
Теперь для этого есть оператор type:
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None
Правая часть вычисляется лениво, только когда её кто-то запросит, поэтому алиас может ссылаться на самого себя или на класс, объявленный ниже, без кавычек. Алиасы бывают generic: type Pair[T] = tuple[T, T].
TypeAlias при этом объявлен устаревшим, но срок удаления не назначен [3].
Вывод вариантности (3.12)
Для повседневной работы это, возможно, самое полезное изменение PEP 695. Раньше вариантность нужно было объявлять руками:
T_co = TypeVar("T_co", covariant=True) class ReadOnlyBox(Generic[T_co]): def __init__(self, item: T_co) -> None: self._item = item def get(self) -> T_co: return self._item
С новым синтаксисом checker выводит вариантность из того, как параметр используется в классе:
class ReadOnlyBox[T]: def __init__(self, item: T) -> None: self._item = item def get(self) -> T: return self._item class MutableBox[T]: def __init__(self, item: T) -> None: self.item = item def show(box: ReadOnlyBox[int]) -> None: ... def edit(box: MutableBox[int]) -> None: ... ro: ReadOnlyBox[bool] = ReadOnlyBox(True) mu: MutableBox[bool] = MutableBox(True) show(ro) # bool является подтипом int, ковариантность выведена edit(mu) # атрибут можно перезаписать, поэтому класс инвариантен
Все три инструмента соглашаются: show(ro) они принимают молча, а на edit(mu) указывают как на ошибку — MutableBox[bool] и MutableBox[int] несовместимы.
Вывод mypy, ty, pyright
$ mypy example.py example.py:22: error: Argument 1 to "edit" has incompatible type "MutableBox[bool]"; expected "MutableBox[int]" [arg-type] Found 1 error in 1 file (checked 1 source file) $ ty check example.py error[invalid-argument-type]: Argument to function `edit` is incorrect --> example.py:22:6 | 22 | edit(mu) # атрибут можно перезаписать, поэтому класс инвариантен | ^^ Expected `MutableBox[int]`, found `MutableBox[bool]` info: `MutableBox` is invariant in its type parameter Found 1 diagnostic $ pyright example.py example.py:22:6 - error: Argument of type "MutableBox[bool]" cannot be assigned to parameter "box" of type "MutableBox[int]" in function "edit" "MutableBox[bool]" is not assignable to "MutableBox[int]" Type parameter "T@MutableBox" is invariant, but "bool" is not the same as "int" (reportArgumentType) 1 error, 0 warnings, 0 informations
@override (3.12)
Декоратор typing.override говорит checker’у, что метод обязан переопределять метод базового класса. В рантайме декоратор ничего не проверяет — он только выставляет атрибут __override__, а вызов метода работает как обычно. Опечатку в имени или переименование в базовом классе замечает только checker:
from typing import override class Base: def run(self) -> None: ... class Child(Base): @override def runn(self) -> None: ... # опечатка, ошибка checker'а
Вывод mypy, ty, pyright
$ mypy example.py example.py:8: error: Method "runn" is marked as an override, but no base method was found with this name [misc] Found 1 error in 1 file (checked 1 source file) $ ty check example.py error[invalid-explicit-override]: Method `runn` is decorated with `@override` but does not override anything --> example.py:8:9 | 7 | @override | --------- 8 | def runn(self) -> None: ... | ^^^^ info: No `runn` definitions were found on any superclasses of `Child` Found 1 diagnostic $ pyright example.py example.py:8:9 - error: Method "runn" is marked as override, but no base method of same name is present (reportGeneralTypeIssues) 1 error, 0 warnings, 0 informations
Значения по умолчанию (3.13)
У параметров типа появились значения по умолчанию (PEP 696). До 3.13 параметр default в typing.TypeVar отсутствовал. В 3.13 он появился, и вместе с ним пришёл новый синтаксис:
from typing import reveal_type class Box[T = int]: def __init__(self, item: T | None = None) -> None: self.item = item def f(b: Box, c: Box[str]) -> None: reveal_type(b) # Box[int], сработал default reveal_type(c) # Box[str]
Голое Box в аннотации означает Box[int], а не Box[Any], и все три инструмента видят это одинаково:
Вывод mypy, ty, pyright
$ mypy example.py example.py:8: note: Revealed type is "example.Box[int]" example.py:9: note: Revealed type is "example.Box[str]" Success: no issues found in 1 source file $ ty check example.py info[revealed-type]: Revealed type --> example.py:8:17 | 8 | reveal_type(b) | ^ `Box[int]` info[revealed-type]: Revealed type --> example.py:9:17 | 9 | reveal_type(c) | ^ `Box[str]` Found 2 diagnostics $ pyright example.py example.py:8:17 - information: Type of "b" is "Box[int]" example.py:9:17 - information: Type of "c" is "Box[str]" 0 errors, 0 warnings, 2 informations
В рантайме значение по умолчанию лежит в
Box.__type_params__[0].__default__.
TypeIs (3.13)
TypeGuard сужает тип только в ветке if, в else checker ничего не знает. TypeIs сужает в обе стороны:
from typing import TypeGuard, TypeIs, reveal_type def is_int(value: int | str) -> TypeIs[int]: return isinstance(value, int) def is_int_guard(value: int | str) -> TypeGuard[int]: return isinstance(value, int) def check_type_is(x: str | int) -> None: if is_int(x): reveal_type(x) # int else: reveal_type(x) # str, TypeIs сузил и else def check_type_guard(y: str | int) -> None: if is_int_guard(y): reveal_type(y) # int else: reveal_type(y) # str | int, TypeGuard else не сужает
Все три инструмента видят одно и то же: TypeIs сужает тип и в if, и в else, TypeGuard — только в if.
Вывод mypy, ty, pyright
$ mypy example.py example.py:11: note: Revealed type is "int" example.py:13: note: Revealed type is "str" example.py:17: note: Revealed type is "int" example.py:19: note: Revealed type is "str | int" Success: no issues found in 1 source file $ ty check example.py info[revealed-type]: Revealed type --> example.py:11:21 | 11 | reveal_type(x) # int | ^ `int` info[revealed-type]: Revealed type --> example.py:13:21 | 13 | reveal_type(x) # str, TypeIs сузил и else | ^ `str` info[revealed-type]: Revealed type --> example.py:17:21 | 17 | reveal_type(y) # int | ^ `int` info[revealed-type]: Revealed type --> example.py:19:21 | 19 | reveal_type(y) # str | int, TypeGuard else не сужает | ^ `str | int` Found 4 diagnostics $ pyright example.py example.py:11:21 - information: Type of "x" is "int" example.py:13:21 - information: Type of "x" is "str" example.py:17:21 - information: Type of "y" is "int" example.py:19:21 - information: Type of "y" is "str | int" 0 errors, 0 warnings, 4 informations
Для функций вида «это str?» TypeIs почти всегда то, что нужно.
ReadOnly (3.13)
ReadOnly помечает ключ TypedDict как неизменяемый:
from typing import ReadOnly, TypedDict class Movie(TypedDict): title: ReadOnly[str] year: int m: Movie = {"title": "Alien", "year": 1979} m["year"] = 1980 # можно, year обычный ключ m["title"] = "Aliens" # нельзя, ReadOnly — ошибка checker'а (не влияет на рантайм)
Единственная строка, которая ломает проверку, — присваивание title, и все три инструмента указывают именно на неё:
Вывод mypy, ty, pyright
$ mypy example.py example.py:9: error: ReadOnly TypedDict key "title" TypedDict is mutated [typeddict-readonly-mutated] Found 1 error in 1 file (checked 1 source file) $ ty check example.py error[invalid-assignment]: Cannot assign to key "title" on TypedDict `Movie` --> example.py:9:3 | 9 | m["title"] = "Aliens" | - ^^^^^^^ key is marked read-only | | | TypedDict `Movie` info: Item declaration --> example.py:4:5 | 4 | title: ReadOnly[str] | -------------------- Read-only item declared here Found 1 diagnostic $ pyright example.py example.py:9:1 - error: Could not assign item in TypedDict "title" is a read-only key in "Movie" (reportTypedDictNotRequiredAccess) 1 error, 0 warnings, 0 informations
Без кавычек (3.14)
В 3.14 аннотации перестали вычисляться в момент определения функции или класса (PEP 649 и 749 [4]). Значит, кавычки вокруг опережающих ссылок больше не нужны:
class Node: def link(self, other: Node) -> Node: return other
Это касается и имён, импортированных только под if TYPE_CHECKING:, пока никто эти аннотации не читает. Про то, что происходит, когда читает, ниже.
Нюансы
type-алиас не класс
Алиас, созданный через type, это объект typing.TypeAliasType, а не сам тип. Проверка isinstance с ним не работает:
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None isinstance({}, Json) # TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union
Само значение доступно через Json.__value__. Если код проверяет типы в рантайме через алиас, обычная переменная Json = dict | list | str | ... остаётся рабочим вариантом.
Правила Ruff UP040, UP046 и UP047 умеют переписывать старый синтаксис в новый, но исправление они помечают как небезопасное [5], и после этого нюанса понятно почему. Я проверил на цели py312. Без --unsafe-fixes Ruff только сообщает о нарушениях. С флагом он переписал Json: TypeAlias = "dict[str, Json] | ..." в type Json = "dict[str, Json] | ..." и оставил кавычки, а это уже строка, а не объединение типов. Результат автоисправления нужно читать глазами.
Новый и старый синтаксис не смешиваются
Класс с параметрами в квадратных скобках не должен наследоваться от Generic[T]:
class A[U](Generic[T]): ... # TypeError: Cannot inherit from Generic[...] multiple times.
Параметры со значением по умолчанию обязаны стоять после параметров без него. Иначе это ошибка на этапе компиляции:
class B[T = int, U]: ... # SyntaxError: non-default type parameter 'U' follows default type parameter
Вариантность больше не написана в коде
Раньше вариантность была видна в имени T_co. Теперь она следствие устройства класса. Стоит добавить в ReadOnlyBox метод set, и он тихо станет инвариантным, а код, передававший ReadOnlyBox[bool] как ReadOnlyBox[int], перестанет проходить проверку. В публичном API библиотеки за этим нужно следить. Если нужна вариантность, отличная от выведенной, остаётся старый TypeVar с явным covariant=True.
В рантайме объект параметра этого не знает. У Box.__type_params__[0] __infer_variance__ равен True, а __covariant__ и __contravariant__ равны False. Вывод делает только checker.
Есть и менее очевидный случай: вариантность зависит не только от того, как класс использует параметр сам, но и от вариантности generic-типа поля, через который T передаётся дальше. list сам по себе инвариантен — в него можно писать, — поэтому T внутри list[T] остаётся инвариантной позицией, даже если класс это поле только читает. Sequence, наоборот, немутируемый интерфейс и ковариантен, так что T внутри Sequence[T] тоже остаётся ковариантным:
from collections.abc import Sequence class ListBox[T]: def __init__(self, items: list[T]) -> None: self._items = items class SeqBox[T]: def __init__(self, items: Sequence[T]) -> None: self._items = items lb: ListBox[bool] = ListBox([True, False]) sb: SeqBox[bool] = SeqBox([True, False]) lb2: ListBox[int] = lb # error: list[T] инвариантен, ListBox[bool] != ListBox[int] sb2: SeqBox[int] = sb # OK: Sequence[T] ковариантен, bool — подтип int
У ListBox и SeqBox конструкторы выглядят одинаково, а поведение разное: ListBox инвариантен, SeqBox ковариантен, и разница целиком в типе поля, а не в теле __init__.
Вывод mypy, ty, pyright
$ mypy example.py example.py:17: error: Incompatible types in assignment (expression has type "ListBox[bool]", variable has type "ListBox[int]") [assignment] Found 1 error in 1 file (checked 1 source file) $ ty check example.py error[invalid-assignment]: Object of type `ListBox[bool]` is not assignable to `ListBox[int]` --> example.py:17:21 | 17 | lb2: ListBox[int] = lb # error: list[T] инвариантен, ListBox[bool] != ListBox[int] | ------------ ^^ Incompatible value of type `ListBox[bool]` | | | Declared type info: `ListBox` is invariant in its type parameter Found 1 diagnostic $ pyright example.py example.py:17:21 - error: Type "ListBox[bool]" is not assignable to declared type "ListBox[int]" "ListBox[bool]" is not assignable to "ListBox[int]" Type parameter "T@ListBox" is invariant, but "bool" is not the same as "int" (reportAssignmentType) 1 error, 0 warnings, 0 informations
Практическое следствие: публичное поле типа list[T] держит класс инвариантным по T, сколько бы класс ни выглядел read-only снаружи — сам list разрешает запись. Когда контейнер только отдаёт значения и нужна ковариантность, тип поля стоит сузить до Sequence[T] или другого неизменяемого generic-протокола, а не оставлять list[T].
TypeIs строже TypeGuard
TypeIs требует, чтобы суженный тип был подтипом типа аргумента:
def bad(x: int) -> TypeIs[str]: return isinstance(x, str) # error: Narrowed type "str" is not a subtype of input type "int" [narrowed-type-not-subtype]
Если функция проверяет что-то, что не является подтипом (например, list[object] на list[str]), нужен TypeGuard. Он сужает только положительную ветку и ничего не гарантирует про else.
reveal_type — не всегда была настоящей функцией
reveal_type, который используется в примерах выше, вошёл в typing тоже в 3.11. До этого имя было соглашением: mypy распознавал вызов reveal_type(x) как отладочную команду, даже без импорта, и печатал тип в свой вывод. На рантайме такого имени просто не было:
x = 1 reveal_type(x) # без импорта
mypy для этого кода по-прежнему пишет Revealed type is "int", но если файл запустить, а не проверить, будет NameError: name 'reveal_type' is not defined — на любой версии Python, включая 3.11. С 3.11 from typing import reveal_type даёт настоящую функцию: она печатает тип в stderr и возвращает аргумент без изменений, поэтому её можно оставлять в коде, а не только в разовых экспериментах.
ReadOnly и override в рантайме
ReadOnly в рантайме не делает ничего. В примере выше m["title"] = "Aliens" на 3.13 спокойно выполняется, а Movie.__readonly_keys__ содержит title. @override тоже ничего не проверяет — он только выставляет атрибут __override__ на функции, рантайм ему верит на слово.
Кто читает аннотации в 3.14
Когда аннотации не вычисляются при определении, ошибка переезжает туда, где их читают. Пример с именем из TYPE_CHECKING:
from typing import TYPE_CHECKING, get_type_hints from annotationlib import Format, get_annotations if TYPE_CHECKING: from decimal import Decimal def total(x: Decimal) -> Decimal: return x print(get_annotations(total, format=Format.FORWARDREF)) # {'x': ForwardRef('Decimal', owner=<function total at ...>), 'return': ForwardRef('Decimal', ...)} print(get_annotations(total, format=Format.STRING)) # {'x': 'Decimal', 'return': 'Decimal'} total.__annotations__ # NameError: name 'Decimal' is not defined get_type_hints(total) # NameError: name 'Decimal' is not defined
def проходит, а вот __annotations__ и get_type_hints() бросают NameError. Новый модуль annotationlib умеет три формата: VALUE (как раньше), FORWARDREF (неизвестные имена превращаются в ForwardRef) и STRING. Библиотекам, которые читают аннотации, What’s New рекомендует переходить на get_annotations() с форматом FORWARDREF, как это сделал dataclasses [4].
Это не теория. Разработчики Mergify описали, как при переходе на 3.14 упал FastAPI 0.128.0, потому что он читал аннотации с именами из
TYPE_CHECKING, а исправление 0.128.1 переключило его наFormat.FORWARDREF[6]. Их порядок действий: сначала обновить фреймворки, потом Python, и только потом убирать кавычки иfrom __future__ import annotations.
Union и | стали одним типом (3.14)
В 3.14 typing.Union и types.UnionType стали одним и тем же. Из этого следует несколько мелочей, которые ломают код, сравнивающий объекты через is или завязанный на repr:
from typing import Union Union[int, str] is Union[int, str] # False (раньше True из-за кеша) Union[int, str] == (int | str) # True repr(Union[int, str]) # 'int | str', а не 'typing.Union[int, str]' isinstance(int | str, Union) # True (раньше TypeError)
Сравнивать объединения стоит через ==, а разбирать через typing.get_origin() и typing.get_args().
А если на 3.12 и выше перейти нельзя
Многие проекты не могут сразу перескочить на 3.14. Зависимости, образ в проде, политика компании. Из нового кое-что доступно и на старых версиях, но не всё, и то, что именно доступно, зависит от типа фичи.
Синтаксис не бэкпортируется
Новый синтаксис относится к парсеру, а не к библиотеке: class C[T], def f[T], оператор type, [T = int] для значения по умолчанию. На 3.10–3.11 такой код падает с SyntaxError: invalid syntax, и никакой пакет это не обходит. from __future__ import annotations тоже не помогает, потому что парсер разбирает файл до того, как импорт на что-то повлияет:
class Box[T]: ... # SyntaxError на <=3.11 class Box[T = int]: ... # SyntaxError на <=3.12
typing_extensions для новых классов
TypeIs, ReadOnly, override и значения по умолчанию у TypeVar не меняют синтаксис. Это обычные объекты модуля typing. Пакет typing_extensions приносит их же на старые версии, поэтому большая часть фич 3.12 и 3.13 доступна на 3.7–3.11 без изменений в рантайме, разница только в источнике импорта.
from __future__ import annotations для аннотаций
Импорт из __future__ (PEP 563, доступен с 3.7) заставляет интерпретатор сохранять все аннотации модуля строками и не вычислять их. Поэтому в аннотации можно написать то, что парсер этой версии разбирает, а интерпретатор вычислить бы не смог. Пример, который без импорта падает на 3.10 и 3.13, а с ним работает:
from __future__ import annotations as _annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from decimal import Decimal from typing_extensions import TypeIs class Node: def link(self, other: Node) -> Node: return other def total(x: Decimal) -> Decimal: return x def is_str(x: object) -> TypeIs[str]: return isinstance(x, str)
Без первой строки на 3.10.20 и 3.13.14 получаем NameError: name 'Node' is not defined, с ней файл импортируется.
Это даёт сразу три вещи в аннотациях:
опережающие ссылки без кавычек (то, что 3.14 сделала по умолчанию),
имена под
TYPE_CHECKING, то есть можно импортироватьtyping_extensionsтолько в dev-зависимостях,list[int]иint | Noneна версиях, где рантайм их ещё не понимает, то есть на 3.7–3.9.
Почему я использую алиас `_annotations`, а не голый `annotations`
Импорт from __future__ import annotations не только флаг для компилятора, он ещё связывает в пространстве имён модуля обычное имя annotations с объектом __future__._Feature:
from __future__ import annotations print(annotations) # _Feature((3, 7, 0, 'beta', 1), None, 16777216)
Это имя ничем не отличается от любой другой переменной модуля: оно попадёт в dir(module), утечёт через from module import *, если в модуле нет __all__.
from __future__ import annotations as _annotations убирает это имя из публичного пространства модуля, а на семантику PEP 563 никак не влияет. Тот же приём использует Pydantic, это видно прямо в pydantic/main.py.
Чего импорт не делает
Он не трогает выражения вне аннотаций. На всех версиях с 3.10 по 3.14, с импортом и без, падают с NameError:
Alias = list[Later] T = TypeVar("T", bound=Later) cast(Later, 1) class Sub(dict[str, Later]): ...
Тут по-прежнему нужны кавычки. Исключение составляет оператор type на 3.12+, потому что его значение ленивое.
Цена импорта
Ошибка не исчезает, а переезжает в тех, кто читает аннотации. На 3.13 с импортом, где Decimal взят из TYPE_CHECKING, get_type_hints(total) бросает NameError, так же как на 3.14. Есть и менее очевидные последствия.
TypedDict разбирает ReadOnly и NotRequired при создании класса. Со строками разбирать нечего:
from __future__ import annotations as _annotations from typing import NotRequired, ReadOnly, TypedDict class Movie(TypedDict): title: ReadOnly[str] year: NotRequired[int] print(sorted(Movie.__required_keys__), sorted(Movie.__optional_keys__), sorted(Movie.__readonly_keys__)) # 3.13 с импортом: ['title', 'year'] [] [] # 3.13 без импорта: ['title'] ['year'] ['title']
Ошибки нет, значения просто неверные. В документации typing это прямо сказано в примечании к __required_keys__.
Ещё один эффект касается методов generic-классов на 3.12 и выше:
from __future__ import annotations as _annotations from typing import get_type_hints class Box[T]: def get(self) -> T: ... get_type_hints(Box.get) # NameError: name 'T' is not defined
Это открытая ошибка cpython#124089, не закрытая по состоянию на 25 сентября 2026 года. Для самой функции (
def first[T]) и дляget_type_hints(Box)она не проявляется, это исправили в cpython#114053.
Потребители аннотаций в рантайме реагируют по-разному. Я проверял Pydantic 2.13.5: модель с полем total: Decimal и Decimal под TYPE_CHECKING создаётся (на 3.13 с импортом из __future__, на 3.14 с ним и без него), но при создании объекта падает с PydanticUserError: ... is not fully defined; you should define Decimal, then call Order.model_rebuild().
Если модели ссылаются на имена из TYPE_CHECKING, придётся вызывать model_rebuild() после того, как имя определено. Ruff помогает в обе стороны: FA100 и FA102 требуют импорт там, где он нужен для Optional, list[str] и int | None, а lint.pyupgrade.keep-runtime-typing = true не даёт переписывать аннотации, которые читает Pydantic.
Что и когда убирать
У двух способов бэкпорта разные сигналы для отказа.
typing_extensions продолжает работать и на новых версиях. typing_extensions.TypeIs остаётся рабочим объектом и на 3.14, ничего не ломается, если импорт не менять.
Переключение на typing снижает число зависимостей, а не исправляет ошибку. Делать его стоит сразу, как только минимальная поддерживаемая версия проекта дотягивает до той, где фича появилась в стандартном typing: override с 3.12, TypeIs, ReadOnly, TypeVar(default=...) с 3.13. Тогда typing_extensions можно убрать из зависимостей.
С from __future__ import annotations ситуация другая. На 3.14+ он не нужен и даже мешает: из-за него Format.FORWARDREF возвращает те же строки, что и STRING, то есть теряет смысл. What’s New 3.14 объявляет импорт устаревшим, но удалять его не будут раньше, чем через два выпуска после конца поддержки Python 3.13 [4]. Убирать стоит, когда минимальная поддерживаемая версия проекта стала 3.14 и библиотеки, читающие аннотации, обновлены до версий, которые с этим справляются.
Итоги
Не нужно сразу переписывать проект. Вот порядок, который выглядит разумным.
Минимальная версия проекта |
Что можно взять |
|---|---|
3.9–3.10 |
|
3.11 |
|
3.12 |
|
3.13 |
|
3.14 |
Аннотации без кавычек и без |
В повседневном коде больше всего меняет class C[T] вместе с выводом вариантности. Пропадают T = TypeVar("T") и суффиксы _co, а вариантность становится неявной. С 3.14 код выглядит так же, но аннотации в рантайме ведут себя иначе, и ошибки приходят из библиотек, которые их читают.
У части нововведений есть цена, которой в анонсах нет: небезопасные автоисправления Ruff, isinstance с type-алиасом, неверные ключи TypedDict под from __future__ import annotations.
Источники
Python Developer’s Guide, по состоянию на 26 сентября 2026 года. Status of Python versions
Python Docs, 2 октября 2023. PEP 695: Type Parameter Syntax
Python Docs, 2 октября 2023. What’s New In Python 3.12 и раздел про устаревшие возможности в документации typing
Python Docs, 7 октября 2025. What’s New In Python 3.14: deferred evaluation of annotations
Astral, Ruff 0.15.22. Правила UP040, UP046, UP047 и UP035
Mergify, 14 апреля 2026. Python 3.14 in Production: What PEP 649 Actually Breaks
mypy. Annotation issues at runtime, а также документация Ruff по правилам FA100, FA102 и UP006
Python Docs, 24 октября 2022. PEP 673: Self Type
Python Docs, 24 октября 2022. PEP 655: Marking Individual TypedDict Items as Required or Potentially-Missing
Python Docs, 24 октября 2022. PEP 646: Variadic Generics