Я читал статьи о типизации в 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’а, интерпретатор их не проверяет, и я буду на это указывать.

Главное: что стало проще

Сводка по версиям, подробности ниже.

Что

Как было

Как стало

С какой версии

Метод возвращает свой класс

TypeVar(bound=...) на каждый класс

Self

3.11

Часть ключей TypedDict не обязательна

total=False в отдельном подклассе

Required/NotRequired

3.11

Generic-класс и функция

T = TypeVar("T") и Generic[T]

class Stack[T], def first[T]

3.12

Алиас типа

X: TypeAlias = "..."

type X = ...

3.12

Вариантность

TypeVar("T_co", covariant=True)

выводится автоматически

3.12

Проверка переопределения

нет

@override

3.12

Значение по умолчанию у параметра типа

TypeVar(default=...)

class Box[T = int]

3.13

Сужение типа

TypeGuard

TypeIs

3.13

Ключи TypedDict только для чтения

нет

ReadOnly[...]

3.13

Опережающие ссылки

кавычки или from __future__ import annotations

без кавычек

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

typing_extensions для Self, Required/NotRequired, TypeIs, ReadOnly, override, TypeVar(default=...). from __future__ import annotations as _annotations, если аннотации не читает Pydantic или FastAPI

3.11

Self, Required/NotRequired — без typing_extensions; для TypeIs, ReadOnly, override, TypeVar(default=...) пакет ещё нужен

3.12

class C[T], def f[T], type, @override, автоматическая вариантность. Быстро найти все места можно просто командой ruff check --select UP035 UP040 UP046 UP047 --target-version py312

3.13

[T = int], TypeIs, ReadOnly без typing_extensions

3.14

Аннотации без кавычек и без from __future__ import annotations. Сначала обновить библиотеки, читающие аннотации

В повседневном коде больше всего меняет class C[T] вместе с выводом вариантности. Пропадают T = TypeVar("T") и суффиксы _co, а вариантность становится неявной. С 3.14 код выглядит так же, но аннотации в рантайме ведут себя иначе, и ошибки приходят из библиотек, которые их читают.

У части нововведений есть цена, которой в анонсах нет: небезопасные автоисправления Ruff, isinstance с type-алиасом, неверные ключи TypedDict под from __future__ import annotations.

Источники

  1. Python Developer’s Guide, по состоянию на 26 сентября 2026 года. Status of Python versions

  2. Python Docs, 2 октября 2023. PEP 695: Type Parameter Syntax

  3. Python Docs, 2 октября 2023. What’s New In Python 3.12 и раздел про устаревшие возможности в документации typing

  4. Python Docs, 7 октября 2025. What’s New In Python 3.14: deferred evaluation of annotations

  5. Astral, Ruff 0.15.22. Правила UP040, UP046, UP047 и UP035

  6. Mergify, 14 апреля 2026. Python 3.14 in Production: What PEP 649 Actually Breaks

  7. mypy. Annotation issues at runtime, а также документация Ruff по правилам FA100, FA102 и UP006

  8. Python Docs, 24 октября 2022. PEP 673: Self Type

  9. Python Docs, 24 октября 2022. PEP 655: Marking Individual TypedDict Items as Required or Potentially-Missing

  10. Python Docs, 24 октября 2022. PEP 646: Variadic Generics

Комментарии (0)