За последнее время я прочитал множество репозиториев, где в Pyhton пользуются типизацией и почти везде синтаксис не новее 3.11: Optional, TypeVar на уровне модуля, Generic[T], кавычки вокруг опережающих ссылок. При этом у Python 3.10 поддержка закончилась, и разработчики языка дали нам новых удобных инструментов и чтобы ими пользоваться не всегда нужно обновляться до последней версии. В этой статье расскажу, какие основные фишки появились за последние несколько лет и как ими пользоваться даже в проектах на старых версиях. Читать далее
Уровень сложностиСредний
Время на прочтение18 мин
Охват и читатели4.9K
Туториал
Я читал статьи о типизации в 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 |
Метод, который должен возвращать экземпляр своего класса, а не базового, раньше требовал 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 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
До 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 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
До 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], новый синтаксис лишь сократил объявление в заголовке класса.
Алиасы раньше объявлялись через 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].
Для повседневной работы это, возможно, самое полезное изменение 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 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
Декоратор 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
У параметров типа появились значения по умолчанию (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 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
TypeIs (3.13)В рантайме значение по умолчанию лежит в
Box.__type_params__[0].__default__.
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 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 помечает ключ 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 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 аннотации перестали вычисляться в момент определения функции или класса (PEP 649 и 749 [4]). Значит, кавычки вокруг опережающих ссылок больше не нужны:
class Node:
def link(self, other: Node) -> Node:
return other
Это касается и имён, импортированных только под if TYPE_CHECKING:, пока никто эти аннотации не читает. Про то, что происходит, когда читает, ниже.
Алиас, созданный через 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 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 требует, чтобы суженный тип был подтипом типа аргумента:
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, который используется в примерах выше, вошёл в 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 в рантайме не делает ничего. В примере выше m["title"] = "Aliens" на 3.13 спокойно выполняется, а Movie.__readonly_keys__ содержит title. @override тоже ничего не проверяет — он только выставляет атрибут __override__ на функции, рантайм ему верит на слово.
Когда аннотации не вычисляются при определении, ошибка переезжает туда, где их читают. Пример с именем из 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].
Union и | стали одним типом (3.14)Это не теория. Разработчики Mergify описали, как при переходе на 3.14 упал FastAPI 0.128.0, потому что он читал аннотации с именами из
TYPE_CHECKING, а исправление 0.128.1 переключило его наFormat.FORWARDREF[6]. Их порядок действий: сначала обновить фреймворки, потом Python, и только потом убирать кавычки иfrom __future__ import annotations.
В 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.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 без изменений в рантайме, разница только в источнике импорта.
Импорт из __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.
Импорт 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
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | lang/python315 - 3.15.0.r3 | 0 | 7.35 | 03-10-2026 |
| 2 | Как Omit {T, K} растворил типы, или что такое дистрибутивность типов в TypeScript | 0 | 6.9 | 19-08-2026 |
| 3 | [Перевод] Не каждый AI-вызов должен генерировать текст | 0 | 10.67 | 02-10-2026 |
| 4 | math/py-networkx - 3.7,2 | 0 | 56.33 | 03-10-2026 |
| 5 | Поменяли object на Lock, и два потока встретились в критической секции | 0 | 8.67 | 30-09-2026 |
| 6 | range-for перестал ронять программу на временном объекте, зато теперь дольше держит мьютекс | 0 | 10.35 | 30-09-2026 |
| 7 | [Перевод] Rust 1.99.0: функции с переменным числом аргументов, информация о схеме размещения типа | 0 | 11.43 | 02-10-2026 |
| 8 | Pony ORM: толстая пачка фич и улучшений | 0 | 9.58 | 26-09-2026 |
| 9 | [Перевод] N‑tier, Clean Architecture и Vertical Slice: практическое сравнение для.NET | 0 | 7.99 | 02-10-2026 |
| 10 | www/py-pywry - 0.6.2_23 | 0 | 27.65 | 02-10-2026 |