Лекция 2. Типы: почему и как#

План#

  1. Зачем типы — что дают и чего не дают.

  2. collections.abc — готовая иерархия абстракций для параметров.

  3. Generics — T как переменная типа.

  4. Protocol — утиная типизация статически + LSP.

  5. Variance — когда list[Dog] подходит вместо list[Animal].

  6. Forward-references — ссылки на ещё не определённые типы.

  7. Overload — точный возвращаемый тип от значения аргумента.

  8. Ещё из typing — TypedDict, Literal, Annotated, Self, Never, Final, ParamSpec, TypeVarTuple.

  9. Gradual typing — Any vs object, включение mypy по одному модулю.

1. Зачем типы#

Три уровня пользы:

  1. Документация которая не врёт. Комментарии врут — код меняется, коммент забыли. Аннотации проверяет mypy.

  2. Отлов багов до тестов. «Передал не то», «забыл про None», «поменял сигнатуру» — ловится без запуска.

  3. IDE навигация. VS Code / PyCharm показывают методы по типу. Без аннотаций — гадают.

Что типы НЕ делают: не ускоряют, не заменяют тесты (int не гарантирует «неотрицательное»), не делают код читаемее автоматически.

Термин: static vs runtime check

  • Static — проверяет тайпчекер (mypy, pyright) до запуска.
  • Runtime — Python проверяет при выполнении (например, isinstance).

Базовые аннотации (int, str, list[int], dict[str, int], X | None, Callable) считаем известными из базового курса Даниэля. Идём дальше — в то, что там не проходили: Protocol, generics, variance.

Шпаргалка по базовым аннотациям
from collections.abc import Callable

# переменные
age: int = 30
name: str = "Alice"
active: bool = True

# контейнеры (PEP 585, 3.9+)
nums: list[int] = [1, 2, 3]
mapping: dict[str, int] = {"a": 1}
pair: tuple[str, int] = ("x", 5)
uniq: set[int] = {1, 2}

# опциональность (PEP 604, 3.10+)
maybe_id: int | None = None

# функции
def greet(name: str, times: int = 1) -> str:
    return name * times

# lambda
handler: Callable[[int, str], bool] = lambda n, s: len(s) > n

typing.List, typing.Dict, typing.Tuple, typing.Set, typing.Optional, typing.Union — в новом коде не нужны. List/Dict/Tuple/Set устарели с 3.9 (PEP 585), Optional/Union формально не устарели — их вытеснил X | Y из 3.10 (PEP 604). В новом коде — встроенные list, dict, tuple, set и X | None / X | Y. ruff с правилами UP006/UP007/UP045 их подсветит и предложит автофикс.

# было (до 3.9)                    ->  стало
from typing import List, Dict, Optional, Union
x: List[int]                       ->  x: list[int]
d: Dict[str, int]                  ->  d: dict[str, int]
maybe: Optional[str]               ->  maybe: str | None
either: Union[int, str]            ->  either: int | str

2. Абстракции из collections.abc#

Мотивация#

Написали функцию:

%%writefile /tmp/abc_motivation.py
def total(items: list[int]) -> int:
    return sum(items)


total([1, 2, 3])            # ok
total((1, 2, 3))            # mypy: tuple вместо list
total(range(10))            # mypy: range вместо list
total(x for x in [1, 2])    # mypy: generator вместо list
Writing /tmp/abc_motivation.py
!uv run mypy --strict /tmp/abc_motivation.py || true
/tmp/abc_motivation.py:6: error: Argument 1 to "total" has incompatible type "tuple[int, int, int]"; expected "list[int]"  [arg-type]
/tmp/abc_motivation.py:7: error: Argument 1 to "total" has incompatible type "range"; expected "list[int]"  [arg-type]
/tmp/abc_motivation.py:8: error: Argument 1 to "total" has incompatible type "Generator[int, None, None]"; expected "list[int]"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

В рантайме всё работает: sum умеет итерироваться по чему угодно. А тайпчекер режет — параметр объявлен как list[int]. Хочется общее имя для «что-то, по чему можно проитерироваться». Оно уже есть в stdlib.

Иерархия collections.abc#

Иерархия collections.abc

Каждый прямоугольник = один абстрактный класс. Внутри — его контракт: dunder-методы, которые наследник пишет сам, и то, что ABC даёт бесплатно (keys, get, index, count). Стрелки — наследование. А внизу связь другая: встроенные типы ABC не наследуют — MutableMapping в dict.__mro__ не найти, — но issubclass(dict, MutableMapping) всё равно True. Класс можно объявить подклассом ABC задним числом, не трогая его код: так сделали со встроенными типами, так же можно подтянуть и чужой класс из библиотеки, которая про ваш ABC не знает. Работает это через отдельный механизм, раскроем его чуть позже в курсе.

Термин: MRO (method resolution order)

Порядок, в котором Python ищет атрибут по цепочке базовых классов. Лежит в Cls.__mro__ — кортеж классов от самого класса до object:

  • dict.__mro__ -> (dict, object)
  • bool.__mro__ -> (bool, int, object)

Это и есть «настоящее» наследование. Всё, чего в __mro__ нет, наследованием не является — даже если issubclass говорит True.

Самые простые ABC — по одному методу:

  • Sized — __len__

  • Iterable[T] — __iter__

  • Container[T] — __contains__

  • Reversible[T] — __reversed__ (наследник Iterable, поэтому ещё и __iter__)

Collection[T] = Sized + Iterable + Container. Mapping/Set/Sequence — три контракта на чтение, Mutable-версии добавляют запись.

Наследоваться необязательно

Свой класс с __contains__ уже считается Container, наследовать не нужно — Container, Sized, Iterable, Reversible, Collection, Hashable, Callable устроены так, что isinstance смотрит на наличие метода. А Mapping, Sequence, Set — нет, одних методов им мало, нужно наследование.

from collections.abc import Container


class Range:
    def __init__(self, lo: int, hi: int) -> None:
        self.lo, self.hi = lo, hi

    def __contains__(self, x: object) -> bool:
        return isinstance(x, int) and self.lo <= x <= self.hi


r = Range(1, 10)
print(5 in r)                        # True — работает без наследования
print(isinstance(r, Container))      # True — метод есть, значит Container
True
True

Наследовать явно (class Range(Container): ...) стоит когда нужны бесплатные mixin-методы — например Sequence наследнику даёт __iter__, __reversed__, index, count «в подарок» из __getitem__ + __len__. Для Container бонусов нет — можно не наследовать.

Напоминание: как выглядят dunder-методы

Ниже — минимальные реализации, чтобы освежить память после базового курса.

class MyBag:
    def __init__(self, items):
        self._items = list(items)

    # для Sized — len(x)
    def __len__(self) -> int:
        return len(self._items)

    # для Iterable — for x in obj / list(obj) / sum(obj)
    def __iter__(self):
        yield from self._items

    # для Container — x in obj
    def __contains__(self, x: object) -> bool:
        return x in self._items

    # для Reversible — reversed(obj)
    def __reversed__(self):
        yield from reversed(self._items)

    # для Sequence — obj[i], obj[a:b]
    def __getitem__(self, i):
        return self._items[i]

    # для MutableSequence — obj[i] = v
    def __setitem__(self, i, v) -> None:
        self._items[i] = v

    # для MutableSequence — del obj[i]
    def __delitem__(self, i) -> None:
        del self._items[i]

    # для сравнения — obj == other
    def __eq__(self, other: object) -> bool:
        return isinstance(other, MyBag) and self._items == other._items

    # для использования в set / как ключа dict — hash(obj)
    def __hash__(self) -> int:
        return hash(tuple(self._items))

Всё что делает ABC — проверяет наличие этих методов и/или подкидывает бесплатные, вычисляемые из них (index, count, keys, items, values, …).

Использование#

Правило: типизируй параметр по минимуму методов, которые реально нужны. Тогда функция примет максимум разных объектов.

Iterable[int] — просто «что-то, что можно проитерировать»:

%%writefile /tmp/iterable_ex.py
from collections.abc import Iterable


def total(items: Iterable[int]) -> int:
    return sum(items)


total([1, 2, 3])          # list
total((1, 2, 3))          # tuple
total(range(10))          # range
total(x for x in [1, 2])  # generator
Writing /tmp/iterable_ex.py
!uv run mypy --strict /tmp/iterable_ex.py
Success: no issues found in 1 source file

Sized — только «у него есть длина»:

%%writefile /tmp/sized_ex.py
from collections.abc import Sized


def is_empty(x: Sized) -> bool:
    return len(x) == 0


is_empty([])       # list
is_empty("")       # str
is_empty({1, 2})   # set
Writing /tmp/sized_ex.py
!uv run mypy --strict /tmp/sized_ex.py
Success: no issues found in 1 source file

Sequence[T] — можно индексировать по int и есть длина. Строже чем Iterable, но принимает list, tuple, str, range:

%%writefile /tmp/sequence_ex.py
from collections.abc import Sequence


def second(items: Sequence[int]) -> int:
    return items[1]        # для Iterable — так нельзя, нет __getitem__


second([1, 2, 3])
second((10, 20, 30))
second(range(5, 10))
Writing /tmp/sequence_ex.py
!uv run mypy --strict /tmp/sequence_ex.py
Success: no issues found in 1 source file

Mapping[K, V] — словарь только для чтения. Принимает dict, collections.OrderedDict и свои классы, унаследованные от Mapping: в отличие от Iterable/Sized, одних методов тут мало.

%%writefile /tmp/mapping_ex.py
from collections.abc import Mapping


def get_name(users: Mapping[int, str], uid: int) -> str:
    return users.get(uid, "unknown")


get_name({1: "Alice", 2: "Bob"}, 1)
Writing /tmp/mapping_ex.py
!uv run mypy --strict /tmp/mapping_ex.py
Success: no issues found in 1 source file

Обратно: чем уже параметр, тем меньше вызывающих подходит. list[int] не примет tuple, Iterable[int] — примет.

Шпаргалка:

Что делаешь с параметром

Тип

Только читаешь длину

Sized

Один раз итерируешь

Iterable[T]

Итерируешь несколько раз

Collection[T] (или Iterable[T] + скопировать в list)

Индексируешь по int, длина

Sequence[T]

Индексируешь по ключу

Mapping[K, V]

Модифицируешь

MutableSequence[T] / MutableMapping[K, V] / MutableSet[T]

Раньше это было в typing

До Python 3.9 generic-версий у list, dict, tuple не было — нельзя было написать list[int]. И у ABC тоже: collections.abc.Iterable был обычным ABC без параметров типа. Поэтому в typing держали параметризованные копии: typing.List, typing.Dict, typing.Iterable, typing.Sequence, typing.Callable — единственный способ написать Iterable[int].

С 3.9 (PEP 585) generic-синтаксис работает у встроенных типов и у ABC напрямую. Теперь:

# было (до 3.9)                           ->  стало
from typing import Iterable, Sequence     ->  from collections.abc import Iterable, Sequence
from typing import List, Dict, Callable   ->  list, dict, from collections.abc import Callable

Всё что в typing с этими именами — устаревшие алиасы, оставлены для обратной совместимости. В новом коде — collections.abc. ruff с правилом UP035 подсветит и предложит автофикс.


3. Generics: T как переменная типа#

Одна функция работает для list[int] и для list[str]. Как это записать в типах?

def first(items):
    return items[0]


print(first([1, 2, 3]))    # int
print(first(["a", "b"]))   # str
1
a
%%writefile /tmp/generic_basic.py
def first[T](items: list[T]) -> T:
    return items[0]


x: int = first([1, 2, 3])   # T = int
y: str = first(["a", "b"])  # T = str
Writing /tmp/generic_basic.py
!uv run mypy --strict /tmp/generic_basic.py
Success: no issues found in 1 source file

Синтаксис [T] — PEP 695 (3.12+). А раньше:

%%writefile /tmp/generic_old.py
from typing import TypeVar

T = TypeVar("T")


def first(items: list[T]) -> T:
    return items[0]
Writing /tmp/generic_old.py
!uv run mypy --strict /tmp/generic_old.py
Success: no issues found in 1 source file

Алиасы типов (type aliases) — тот же PEP 695:

%%writefile /tmp/type_alias.py
type UserId = int
type UserMap = dict[UserId, str]


def load(users: UserMap) -> UserId | None:
    for uid in users:
        return uid
    return None
Writing /tmp/type_alias.py
!uv run mypy --strict /tmp/type_alias.py
Success: no issues found in 1 source file

type X = Y — ленивый: правая часть вычисляется не сразу, а при первом обращении к значению алиаса (X.__value__). Значит X может ссылаться на имя, определённое ниже по файлу (это называется forward reference) — или на самого себя, то есть быть рекурсивным:

type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None

sample: JSON = {"a": [1, 2, {"b": None}]}
print(sample)
{'a': [1, 2, {'b': None}]}

А как было до PEP 695#

Попытка 1: обычное присваивание — правая часть вычисляется сразу:

Nodes = list[TreeNode]   # TreeNode ещё не определён


class TreeNode:
    children: Nodes
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[21], line 1
----> 1 Nodes = list[TreeNode]   # TreeNode ещё не определён
      2 
      3 
      4 class TreeNode:

NameError: name 'TreeNode' is not defined

NameError — правая часть list[TreeNode] пытается зарезолвить TreeNode немедленно, а класса ещё нет.

Попытка 2: строка внутри правой части — тайпчекер видит «TreeNode» как forward-ref, рантайм — как обычную строку, не пытается её резолвить:

%%writefile /tmp/alias_string.py
Nodes = list["TreeNode"]  # "TreeNode" в кавычках — forward-ref строкой


class TreeNode:
    children: Nodes
Writing /tmp/alias_string.py
!uv run mypy --strict /tmp/alias_string.py
Success: no issues found in 1 source file

В двусмысленных случаях алиас помечали явно через TypeAlias (PEP 613) — тогда mypy не путал его с обычной переменной:

%%writefile /tmp/alias_typealias.py
from typing import TypeAlias

Nodes: TypeAlias = list["TreeNode"]  # явно: это алиас, а не runtime-значение


class TreeNode:
    children: Nodes
Writing /tmp/alias_typealias.py
!uv run mypy --strict /tmp/alias_typealias.py
Success: no issues found in 1 source file

В сумме, у второй попытки:

  • forward-ref только через строку — легко опечататься, тайпчекер найдёт (если запустишь), рантайм узнает поздно;

  • рекурсивные типы (JSON = dict[str, "JSON"] | ...) без гимнастики со строками не выразить;

  • TypeAlias — ещё одна конструкция и ещё один импорт, которые надо помнить.

PEP 695 решает всё сразу — ленивость на уровне языка, без строк, без импорта:

type Nodes = list[TreeNode]        # ok, TreeNode может быть ниже
type JSON = dict[str, JSON] | ...  # ok, рекурсия работает

4. Protocol и утиная типизация статически#

Как выразить «любой объект с методом close()»? Наследование от общей базы не работает — объект может быть из чужой библиотеки.

Утиная и гусиная типизация — напоминание#

У Даниэля разбирали два подхода:

  • Утиная типизация — «ходит как утка, крякает как утка -> утка». Смотрим только на наличие методов, никаких isinstance и наследований. Проверка в runtime — упадёт если метод не нашёлся.

  • Гусиная типизация — класс сам заявляет, что реализует интерфейс: наследуется от abc.ABC, а isinstance проверяет эту пометку.

typing.Protocol — это утиная типизация статически: mypy смотрит на форму (какие методы есть), но проверяет до запуска.

Гусиная (ABC) vs Утиная (Protocol)

Академические названия

В доках mypy, PEP и учебниках то же самое зовут nominal typing (гусиная, по имени/наследованию) и structural typing (утиная, по структуре). Дальше в лекции — «гусиная»/»утиная», но в документации вы встретите именно эту пару.

ABC vs Protocol#

%%writefile /tmp/abc_example.py
from abc import ABC, abstractmethod


class Closeable(ABC):
    @abstractmethod
    def close(self) -> None: ...


class File(Closeable):  # обязательно наследуем
    def close(self) -> None: ...


class Socket:  # НЕ наследуем
    def close(self) -> None: ...


def cleanup(x: Closeable) -> None:
    x.close()


cleanup(File())    # ok
cleanup(Socket())  # error — Socket не наследуется от Closeable
Writing /tmp/abc_example.py
!uv run mypy --strict /tmp/abc_example.py || true
/tmp/abc_example.py:22: error: Argument 1 to "cleanup" has incompatible type "Socket"; expected "Closeable"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
%%writefile /tmp/protocol_example.py
from typing import Protocol


class Closeable(Protocol):
    def close(self) -> None: ...


class File:
    def close(self) -> None: ...


class Socket:
    def close(self) -> None: ...


def cleanup(x: Closeable) -> None:
    x.close()


cleanup(File())    # ok
cleanup(Socket())  # ok — есть .close(), этого достаточно
Writing /tmp/protocol_example.py
!uv run mypy --strict /tmp/protocol_example.py
Success: no issues found in 1 source file

Protocol для чужого, ABC для своего.

runtime_checkable#

По умолчанию Protocol — только static. Для isinstance нужен декоратор:

from typing import Protocol, runtime_checkable


@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...


class File:
    def close(self) -> None: ...


print(isinstance(File(), Closeable))    # True
print(isinstance("hello", Closeable))   # False
True
False

Проверяет только наличие методов, не сигнатуры. Медленно для больших Protocol’ов.

Liskov substitution: как ломается наследование#

LSP — это L из SOLID, набора из пяти принципов ООП-дизайна (Роберт Мартин, 2000-е). В контексте типов интересен именно L — остальные под катом.

SOLID полностью
  • S — Single Responsibility Principle. Класс должен иметь одну причину для изменения. Один класс = одна ответственность.

  • O — Open/Closed Principle. Код открыт для расширения (новые фичи через новые классы/функции), закрыт для модификации (не переписываем существующее).

  • L — Liskov Substitution Principle. Подкласс должен быть подставим вместо родителя без нарушения работы клиента. Разбираем ниже.

  • I — Interface Segregation Principle. Много маленьких протоколов лучше одного большого — не заставляем клиента зависеть от методов, которые он не использует. У нас в §4 это «Protocol как минимальный контракт».

  • D — Dependency Inversion Principle. Высокоуровневые модули не зависят от низкоуровневых напрямую — оба зависят от абстракции (Protocol / ABC). В Python — тайпнули параметр как Protocol, и подставится любая реализация.

LSP (Барбара Лисков, 1987): подкласс должен быть взаимозаменяем (substitutable) с родителем — везде, где ожидается объект родителя, должен подойти объект подкласса без сюрпризов для клиента.

LSP: клиент, база, подкласс

Клиент работает через Base, но реальный объект может быть Subclass. Значит контракт Base должен выполняться у любого потомка. Три типовых нарушения:

1. Возвращаемое значение: подкласс возвращает шире -> плохо.

%%writefile /tmp/lsp_return.py
class Animal: ...
class Dog(Animal): ...


class Factory:
    def create(self) -> Dog:
        return Dog()


class BadFactory(Factory):
    def create(self) -> Animal:  # Animal шире Dog — клиент ждёт Dog
        return Animal()
Writing /tmp/lsp_return.py
!uv run mypy --strict /tmp/lsp_return.py || true
/tmp/lsp_return.py:11: error: Return type "Animal" of "create" incompatible with return type "Dog" in supertype "Factory"  [override]
Found 1 error in 1 file (checked 1 source file)

2. Аргумент: подкласс принимает уже -> плохо.

%%writefile /tmp/lsp_args.py
class Animal: ...
class Dog(Animal): ...


class Handler:
    def handle(self, x: Animal) -> None: ...


class BadHandler(Handler):
    def handle(self, x: Dog) -> None: ...  # Dog уже Animal
Writing /tmp/lsp_args.py
!uv run mypy --strict /tmp/lsp_args.py || true
/tmp/lsp_args.py:10: error: Argument 1 of "handle" is incompatible with supertype "Handler"; supertype defines the argument type as "Animal"  [override]
/tmp/lsp_args.py:10: note: This violates the Liskov substitution principle
/tmp/lsp_args.py:10: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
Found 1 error in 1 file (checked 1 source file)

3. Добавили обязательный аргумент -> плохо.

%%writefile /tmp/lsp_arity.py
class Base:
    def process(self, item: int) -> None: ...


class Bad(Base):
    def process(self, item: int, priority: int) -> None: ...
Writing /tmp/lsp_arity.py
!uv run mypy --strict /tmp/lsp_arity.py || true
/tmp/lsp_arity.py:6: error: Signature of "process" incompatible with supertype "Base"  [override]
/tmp/lsp_arity.py:6: note:      Superclass:
/tmp/lsp_arity.py:6: note:          def process(self, item: int) -> None
/tmp/lsp_arity.py:6: note:      Subclass:
/tmp/lsp_arity.py:6: note:          def process(self, item: int, priority: int) -> None
Found 1 error in 1 file (checked 1 source file)

Общее правило: возвращать можно уже, принимать — шире. Дальше увидим ту же логику в generic’ах.


5. Variance: list[Dog] vs list[Animal]#

Знаем: Dog подкласс Animal. А list[Dog] — это list[Animal]?

Ответ зависит от того, что тип делает с T.

Covariance / Contravariance / Invariance

Кратко:

  • Только producer (выдаёт T наружу) — covariant, движется в ту же сторону что Dog <: Animal.

  • Только consumer (принимает T внутрь) — contravariant, в обратную.

  • И то и то — invariant, ни туда ни сюда.

list — invariant#

%%writefile /tmp/variance_list.py
class Animal: ...
class Dog(Animal):
    def bark(self) -> str: return "woof"
class Cat(Animal): ...


def add_cat(animals: list[Animal]) -> None:
    animals.append(Cat())  # если бы list был covariant — сюда пролез бы Cat


def first_bark(dogs: list[Dog]) -> str:
    return dogs[0].bark()  # ждём Dog — вызываем .bark()


dogs: list[Dog] = [Dog()]
add_cat(dogs)          # если бы list[Dog] <: list[Animal] — прошло бы
first_bark(dogs)       # но тогда в runtime внутри мог оказаться Cat, и .bark() упадёт


animals: list[Animal] = [Cat(), Cat()]
first_bark(animals)    # обратно тоже нельзя: если бы list[Animal] <: list[Dog] —
                       # прошло бы, а внутри .bark() у Cat нет
Writing /tmp/variance_list.py
!uv run mypy --strict /tmp/variance_list.py || true
/tmp/variance_list.py:16: error: Argument 1 to "add_cat" has incompatible type "list[Dog]"; expected "list[Animal]"  [arg-type]
/tmp/variance_list.py:16: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
/tmp/variance_list.py:16: note: Consider using "Sequence" instead, which is covariant
/tmp/variance_list.py:21: error: Argument 1 to "first_bark" has incompatible type "list[Animal]"; expected "list[Dog]"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Mypy правильно ругается — list invariant.

Sequence — covariant#

Формально: Sequence[Dog] <: Sequence[Animal]. Стрелка сохраняется относительно Dog <: Animal — потому и «covariance».

Почему: Sequence[Dog] умеет выдавать только Dog, а Dog — это и есть Animal. Функция, ждущая Sequence[Animal], будет только читать элементы и обращаться с ними как с Animal — Dog подходит.

%%writefile /tmp/variance_sequence.py
from collections.abc import Sequence


class Animal:
    def speak(self) -> str: return "..."
class Dog(Animal): ...


def describe_first(animals: Sequence[Animal]) -> str:
    return animals[0].speak()   # индексируем и читаем — Animal-контракта хватает


dogs: list[Dog] = [Dog(), Dog()]
describe_first(dogs)   # ok — Sequence только читает, Dog <: Animal
Writing /tmp/variance_sequence.py
!uv run mypy --strict /tmp/variance_sequence.py
Success: no issues found in 1 source file

Pushable — contravariant#

Формально: Pushable[Animal] <: Pushable[Dog]. Стрелка разворачивается относительно Dog <: Animal — потому и «contravariance».

Почему: Pushable[Animal] умеет глотать любое Animal (в том числе Dog). Если функция ждёт Pushable[Dog] — то есть будет пихать туда только Dog, — Pushable[Animal] подходит с запасом.

%%writefile /tmp/variance_consumer.py
from typing import Protocol, TypeVar

T_contra = TypeVar("T_contra", contravariant=True)


class Animal: ...
class Dog(Animal): ...


class Pushable(Protocol[T_contra]):
    def push(self, item: T_contra) -> None: ...


class AnimalLog:
    def push(self, item: Animal) -> None: ...


class DogLog:
    def push(self, item: Dog) -> None: ...


def dump_dogs(sink: Pushable[Dog]) -> None:
    sink.push(Dog())


# AnimalLog глотает любое Animal — Dog для него тем более ok
dump_dogs(AnimalLog())   # ok — contravariance
dump_dogs(DogLog())      # ok — точное совпадение
Writing /tmp/variance_consumer.py
!uv run mypy --strict /tmp/variance_consumer.py
Success: no issues found in 1 source file

Здесь мы явно объявили TypeVar contravariant через contravariant=True и суффикс _contra в имени — самодокументирующий контракт «этот протокол только принимает T внутрь». PEP 695 (class Pushable[T](Protocol)) тоже работает — mypy выведет contravariance сам из того что T только в аргументе метода. Но вывод типов молчит: контракт виден только из тела, из объявления — нет. В API-коде явное лучше.

Callable — обе стороны#

Callable[[Arg], Ret] — generic по двум позициям с разной variance:

  • Возвращаемый тип — covariant (producer).

  • Аргумент — contravariant (consumer).

%%writefile /tmp/callable_variance.py
from collections.abc import Callable


class Animal: ...
class Dog(Animal): ...


def take_animal(a: Animal) -> None: ...
def take_dog(d: Dog) -> None: ...


# Функция, принимающая шире (Animal), — годится там где ждут функцию для Dog.
handler_ok: Callable[[Dog], None] = take_animal    # ok

# Обратное — нельзя.
handler_bad: Callable[[Animal], None] = take_dog   # error
Writing /tmp/callable_variance.py
!uv run mypy --strict /tmp/callable_variance.py || true
/tmp/callable_variance.py:16: error: Incompatible types in assignment (expression has type "Callable[[Dog], None]", variable has type "Callable[[Animal], None]")  [assignment]
Found 1 error in 1 file (checked 1 source file)

Это ровно та же LSP-логика из §4, только на generic’ах.

type[T] — сам класс, не экземпляр#

type[T] vs instance

%%writefile /tmp/type_t.py
class Animal: ...
class Dog(Animal): ...


def make(cls: type[Animal]) -> Animal:
    return cls()


make(Dog)   # ok — Dog это type[Animal]
make(int)   # error — int не подкласс Animal
Writing /tmp/type_t.py
!uv run mypy --strict /tmp/type_t.py || true
/tmp/type_t.py:10: error: Argument 1 to "make" has incompatible type "type[int]"; expected "type[Animal]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Живой пример — фабрики / реестры: функция принимает cls: type[Base] и возвращает cls().

Bounded TypeVar#

Хочется generic только для сравнимых типов. Наивная попытка — обычный класс Comparable как bound:

%%writefile /tmp/bounded_nominal.py
class Comparable:
    def __lt__(self, other: Comparable, /) -> bool:
        raise NotImplementedError


def sort_pair[T: Comparable](a: T, b: T) -> tuple[T, T]:
    return (a, b) if a < b else (b, a)


sort_pair(1, 2)      # int не наследуется от Comparable
sort_pair("a", "b")  # str не наследуется от Comparable
Writing /tmp/bounded_nominal.py
!uv run mypy --strict /tmp/bounded_nominal.py || true
/tmp/bounded_nominal.py:10: error: Value of type variable "T" of "sort_pair" cannot be "int"  [type-var]
/tmp/bounded_nominal.py:11: error: Value of type variable "T" of "sort_pair" cannot be "str"  [type-var]
Found 2 errors in 1 file (checked 1 source file)

Mypy режет: int и str формально не подклассы нашего Comparable, хотя __lt__ у них есть. Это гусиная типизация — проверка по имени/наследованию. Для встроенных типов не работает.

Решение — сделать Comparable Protocol. Тогда mypy проверит по форме (наличию __lt__), а не по наследованию:

%%writefile /tmp/bounded_protocol.py
from typing import Protocol, Self


class Comparable(Protocol):
    def __lt__(self, other: Self, /) -> bool: ...


def sort_pair[T: Comparable](a: T, b: T) -> tuple[T, T]:
    return (a, b) if a < b else (b, a)


sort_pair(1, 2)      # у int есть __lt__ — подходит
sort_pair("a", "b")  # у str есть __lt__ — подходит
Writing /tmp/bounded_protocol.py
!uv run mypy --strict /tmp/bounded_protocol.py
Success: no issues found in 1 source file

Правило: bounded TypeVar, который планируешь применять к встроенным / чужим типам, — почти всегда Protocol.


6. Forward-references (кратко)#

Ссылка на класс, который ещё не определён:

class Node:
    def __init__(self, value: int, next: Node | None = None) -> None:
        self.value = value
        self.next = next


print(Node.__init__.__annotations__)
{'value': <class 'int'>, 'next': __main__.Node | None, 'return': None}

На 3.14 это работает: PEP 649 — аннотации ленивы, резолвятся при первом обращении к __annotations__. На 3.13 и раньше — падает NameError, надо было либо кавычить ("Node | None"), либо ставить from __future__ import annotations (PEP 563).

Практический вывод: в аннотациях на 3.14 — забудь про кавычки и from __future__ import annotations, всё работает.


7. Overload#

Возврат зависит от значения аргумента, не типа. Типичное — open("f", "r") -> TextIO, open("f", "rb") -> BinaryIO. Union плох: клиент получит TextIO | BinaryIO и вынужден делать isinstance.

Ниже используется Literal["r"] — тип, разрешающий ровно одно конкретное значение (строку "r", никакую другую). Разберём его подробно в следующем разделе, здесь достаточно понимать, что «передал не эту строку — тайпчекер отругал».

%%writefile /tmp/overload_open.py
from typing import Literal, TextIO, BinaryIO, overload


@overload
def my_open(path: str, mode: Literal["r"]) -> TextIO: ...
@overload
def my_open(path: str, mode: Literal["rb"]) -> BinaryIO: ...


def my_open(path: str, mode: str) -> TextIO | BinaryIO:  # реализация
    if mode == "r":
        return open(path)
    return open(path, "rb")


text = my_open("a.txt", "r")     # mypy знает: TextIO
binary = my_open("a.bin", "rb")  # mypy знает: BinaryIO
Writing /tmp/overload_open.py
!uv run mypy --strict /tmp/overload_open.py
Success: no issues found in 1 source file
  • Заглушки @overload — только ..., без тела.

  • Реализация — последняя, без @overload.

  • Порядок важен: mypy берёт первый матч.


8. Ещё из typing#

TypedDict — типы для dict’ов#

JSON пришёл в dict, ключи известны. Заменяем dict[str, Any].

%%writefile /tmp/td_typed.py
from typing import TypedDict


class User(TypedDict):
    id: int
    name: str
    email: str


def get_name(user: User) -> str:
    name: str = user["nam"]  # error: нет ключа "nam"
    return name
Writing /tmp/td_typed.py
!uv run mypy --strict /tmp/td_typed.py || true
/tmp/td_typed.py:11: error: TypedDict "User" has no key "nam"  [typeddict-item]
/tmp/td_typed.py:11: note: Did you mean "name"?
Found 1 error in 1 file (checked 1 source file)

Опциональные поля — PEP 655:

%%writefile /tmp/td_required.py
from typing import TypedDict, NotRequired


class User(TypedDict):
    id: int
    name: str
    email: NotRequired[str]  # может отсутствовать


ok: User = {"id": 1, "name": "Alice"}    # email класть необязательно
bad: User = {"id": 2, "email": "a@b.c"}  # error — обязательный "name" пропущен


def send(u: User) -> None:
    print(u["email"])            # mypy молчит, а в runtime это KeyError


def send_ok(u: User) -> None:
    print(u.get("email", "-"))   # безопасно
Writing /tmp/td_required.py
!uv run mypy --strict /tmp/td_required.py || true
/tmp/td_required.py:11: error: Missing key "name" for TypedDict "User"  [typeddict-item]
Found 1 error in 1 file (checked 1 source file)

Ругается только на конструирование: пропущенный обязательный ключ — ошибка, отсутствующий NotRequired — нет. А вот чтение u["email"] mypy не проверяет вообще: наличие опциональных ключей он не отслеживает. Поэтому .get() с дефолтом.

Термин: сужение типа (type narrowing)

Mypy сужает тип после проверки: isinstance(x, int), if x is not None, match. Внутри ветки тип становится уже.

%%writefile /tmp/narrowing.py
from typing import TypedDict, NotRequired, reveal_type


class User(TypedDict):
    id: int
    email: NotRequired[str]


def f(x: int | None) -> None:
    if x is not None:
        reveal_type(x)   # сузилось


def g(u: User) -> None:
    if "email" in u:
        reveal_type(u)   # не сузилось
Writing /tmp/narrowing.py
!uv run mypy --strict /tmp/narrowing.py
/tmp/narrowing.py:11: note: Revealed type is "int"
/tmp/narrowing.py:16: note: Revealed type is "TypedDict(narrowing.User, {'id': int, 'email'?: str})"
Success: no issues found in 1 source file

int | None стало int, а TypedDict остался с тем же email? — проверка in у него ничего не сужает.

NotRequired[str] vs str | None

  • email: NotRequired[str] — ключа может не быть в dict.
  • email: str | None — ключ есть, значение может быть None.

TypedDict — чисто информация для тайпчекера. В рантайме это обычный dict, никаких проверок (что id действительно int, что name действительно str).

Literal — конкретные значения как типы#

PEP 586. Не «строка», а строго "r" | "rb".

%%writefile /tmp/literal_basic.py
from typing import Literal

Mode = Literal["r", "rb", "w", "wb"]


def do_open(path: str, mode: Mode) -> None: ...


do_open("f.txt", "r")   # ok
do_open("f.txt", "rw")  # error: "rw" не в Literal
Writing /tmp/literal_basic.py
!uv run mypy --strict /tmp/literal_basic.py || true
/tmp/literal_basic.py:10: error: Argument 2 to "do_open" has incompatible type "Literal['rw']"; expected "Literal['r', 'rb', 'w', 'wb']"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Literal vs Enum

Как только вариантов больше 2-3 или хочется автодополнения / рефакторинга — лучше enum.Enum:

from enum import Enum

class Mode(Enum):
    READ = "r"
    READ_BINARY = "rb"
    WRITE = "w"
    WRITE_BINARY = "wb"

def do_open(path: str, mode: Mode) -> None: ...

do_open("f.txt", Mode.READ)

Плюсы Enum: IDE подставит варианты по Mode., переименование значения — одно место, легко добавить методы (is_binary()), в рантайме — настоящий объект (можно передать между модулями, сравнить через is).

Literal остаётся хорош для флагов-паттернов, приходящих извне как есть (JSON, CLI, open(mode=»r») в stdlib), где enum-обёртка была бы лишней.

Annotated — метаданные к типу#

PEP 593. Прицепить к типу произвольный объект. Для mypy — исходный тип, для рантайм-фреймворков — источник конфигурации.

from typing import Annotated, get_type_hints


class Positive:
    def __init__(self, min_value: int = 0) -> None:
        self.min_value = min_value


Age = Annotated[int, Positive(min_value=0)]


def greet(age: Age) -> str:
    return f"you are {age}"


# для mypy Age это int
greet(30)

# метаданные — через get_type_hints с include_extras=True
hints = get_type_hints(greet, include_extras=True)
print(hints["age"].__metadata__)
(<__main__.Positive object at 0x10a7e3e00>,)

Так работают:

  • pydantic v2 — Annotated[int, Field(gt=0)]

  • FastAPI — Annotated[Session, Depends(get_db)]

  • msgspec — Annotated[int, msgspec.Meta(ge=0)]

  • typer — Annotated[int, typer.Option("--port")]

Self — цепочки методов через .#

PEP 673.

%%writefile /tmp/self_builder.py
from typing import Self


class Query:
    def __init__(self) -> None:
        self._where: list[str] = []

    def where(self, cond: str) -> Self:
        self._where.append(cond)
        return self


class UserQuery(Query):
    def active(self) -> Self:
        return self.where("active = true")


# .where() у UserQuery возвращает UserQuery, не Query — цепочка не теряет тип
q: UserQuery = UserQuery().where("age > 18").active()
Writing /tmp/self_builder.py
!uv run mypy --strict /tmp/self_builder.py
Success: no issues found in 1 source file

Без Self пришлось бы писать TypeVar("T", bound="Query") — с кавычками, потому что TypeVar объявляется до класса и вычисляет bound сразу, а имени Query в этот момент ещё нет.

Never / assert_never — exhaustive match#

PEP 484 даёт NoReturn. Never и assert_never добавили в 3.11 — для проверки полноты match/case.

%%writefile /tmp/never_match.py
from typing import Literal, assert_never

Colour = Literal["red", "green", "blue"]


def rgb(c: Colour) -> tuple[int, int, int]:
    match c:
        case "red":
            return (255, 0, 0)
        case "green":
            return (0, 255, 0)
        # забыли "blue"
        case _:
            assert_never(c)  # error: "blue" не покрыт
Writing /tmp/never_match.py
!uv run mypy --strict /tmp/never_match.py || true
/tmp/never_match.py:14: error: Argument 1 to "assert_never" has incompatible type "Literal['blue']"; expected "Never"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Пропустил кейс — mypy покажет какой именно.

Final — константы#

PEP 591. Запрет переприсваивания.

%%writefile /tmp/final_const.py
from typing import Final

MAX_RETRIES: Final = 3


def retry() -> None:
    global MAX_RETRIES
    MAX_RETRIES = 5  # mypy: error, runtime: спокойно переприсвоится


retry()
print(MAX_RETRIES)
Writing /tmp/final_const.py
!uv run mypy --strict /tmp/final_const.py || true
/tmp/final_const.py:8: error: Cannot assign to final name "MAX_RETRIES"  [misc]
Found 1 error in 1 file (checked 1 source file)

А в рантайме всё работает — Python про Final ничего не знает:

!uv run python /tmp/final_const.py
5

@final на классе — запрет наследования, на методе — запрет переопределения:

%%writefile /tmp/final_class.py
from typing import final


class Base:
    @final
    def critical(self) -> None: ...


class Sub(Base):
    def critical(self) -> None: ...  # error: cannot override final method
Writing /tmp/final_class.py
!uv run mypy --strict /tmp/final_class.py || true
/tmp/final_class.py:10: error: Cannot override final attribute "critical" (previously declared in base class "Base")  [misc]
Found 1 error in 1 file (checked 1 source file)

Final vs UPPER_CASE

В Python нет настоящих констант — MAX_RETRIES = 3 можно переприсвоить. UPPER_CASE — только конвенция. Final — реальная проверка mypy. Для настоящей неизменяемости в runtime — frozen dataclass / tuple.

ParamSpec — типизируем декораторы#

Как типизировать @logged, который сохраняет сигнатуру декорируемой функции?

%%writefile /tmp/paramspec_demo.py
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING

def logged[**P, R](f: Callable[P, R]) -> Callable[P, R]:
    @wraps(f)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {f.__name__}({args}, {kwargs})")
        return f(*args, **kwargs)
    return wrapper

@logged
def greet(name: str, age: int = 30) -> str:
    return f"hello {name}, {age}"

print(greet("Ivan", age=25))

if TYPE_CHECKING:
    reveal_type(greet)
# greet(123)  # mypy сообщит, что ожидается str
Writing /tmp/paramspec_demo.py
!mypy --strict /tmp/paramspec_demo.py
!python /tmp/paramspec_demo.py
/tmp/paramspec_demo.py:19: note: Revealed type is "def (name: str, age: int =) -> str"
Success: no issues found in 1 source file
calling greet(('Ivan',), {'age': 25})
hello Ivan, 25

TypeVarTuple — набор типов произвольной длины#

TypeVarTuple описывает набор типов произвольной длины. Можно выделить первый аргумент функции и сохранить типы всех остальных:

%%writefile /tmp/typevartuple_demo.py
from typing import TYPE_CHECKING

def first_and_rest[T, *Ts](first: T, *rest: *Ts) -> tuple[T, tuple[*Ts]]:
    return first, rest

result = first_and_rest(1, "hello", 3.14)
print(result)

if TYPE_CHECKING:
    reveal_type(result)
Writing /tmp/typevartuple_demo.py
!mypy --strict /tmp/typevartuple_demo.py
!python /tmp/typevartuple_demo.py
/tmp/typevartuple_demo.py:10: note: Revealed type is "tuple[int, tuple[str, float]]"
Success: no issues found in 1 source file
(1, ('hello', 3.14))

T — тип первого аргумента, а Ts — типы оставшихся, здесь str и float. Первый аргумент обязателен; оставшихся может быть сколько угодно, в том числе ноль.


9. Gradual typing#

Python — динамический язык. Типы — опциональный слой: включаем где помогает, отключаем где мешает.

Any vs object#

Any vs object

Any отключает проверки. Один Any заражает всё вокруг:

%%writefile /tmp/any_leak.py
from typing import Any


def parse(data: Any) -> Any:
    return data["result"]


x = parse({"result": 42})
y = x + "wrong"  # runtime TypeError; mypy думал x: Any -> всё ок
Writing /tmp/any_leak.py
!uv run mypy --strict /tmp/any_leak.py
Success: no issues found in 1 source file

Правило: Any — только на границе с нетипизированным кодом. Внутри своей логики — никогда.

Плохо: def process(data: Any) -> Any: …

Хорошо: def process(data: str) -> dict[str, Any]: … — Any ограничен значениями dict’а, границей внешнего JSON-контракта.

Строгий режим по одному модулю#

Реальный проект 50k строк без типов. Включить strict глобально — нереально: сотни ошибок сразу. Включаем по одному модулю: строгий mypy для новых модулей, ignore_errors для старых. Постепенно двигаем модули из «игнора» в «строго».

[tool.mypy]
python_version = "3.14"
strict = true

[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = true

[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true

# type: ignore[код] — с явным кодом#

result = untyped_lib.get()  # type: ignore                   — плохо (закроет любую будущую ошибку)
result = untyped_lib.get()  # type: ignore[no-untyped-call]  — хорошо

Guido van Rossum, Python Language Summit 2016

»Type hints are optional and will always remain that way.»

Про то когда включать: »The larger a codebase is, the more benefit it will get from adding annotations and actually checking them with a tool.» — практический совет »sooner than you would like to».

Источник: LWN — The state of mypy.


Материалы#