Типизация в Python (ru)
11-02-2026
В этой статье рассматриваются два ключевых вопроса. Зачем использовать типизацию в языке Python, который позволяет писать без неё? И как писать типизированный код на Python правильно? Постараемся быстро познакомиться со всеми необходимыми инструментами, чтобы после прочтения вы уже могли начать осознанно писать свои программы типизированными, ведь это совсем не сложно!
# Динамика vs Статика
Итак, что же такое типизация? Новым это понятие может оказаться только для питонистов, ведь большинство классических языков таких как C, Java, Rust и многие другие исходно были созданы, как языки со статической типизацией. Но что это означает? Давайте рассмотрим небольшой пример на C:
int sum(int a, int b) {
return a + b;
}
int main() {
printf("%d\n", sum(10, 20));
// printf("%d\n", sum("10", 20));
}Такой код работает и выводит число 30. Но обратите внимание,
что последняя строка закомментирована. Если мы раскомментируем
её и опять попробуем скомпилировать программу, то получим
примерно вот такой лог ошибки:
error: passing argument 1 of ‘sum’ makes integer from pointer without cast
10 | printf("%d\n", sum("10", 20));
| ^~~~
| |
| char *
note: expected ‘int’ but argument is of type ‘char *’Лог сообщает нам, что параметр функции, ожидая int, получил
аргумент типа char * (для упрощения можем считать это
эквивалентом строки). На первый взгляд ничего удивительного, для
нас с вами — питонистов, в этом нет. Ведь вот такой код на Python
тоже упал бы с ошибкой:
def sum(a, b):
return a + b
print(sum("10", 20)) # TypeErrorВ чем же разница, спросите вы? Давайте немного подправим оба этих
примера на C и на Python. Попробуем вызвать функцию sum от двух
строк:
print(sum("10", "20")) # > 1020Здесь мы не получаем никаких ошибок, потому что действует полиморфизм, а для строк операция сложения тоже реализована. Но что будет в C?
int main() {
printf("%d\n", sum("10", "20"));
}Такую программу не выйдет даже скомпилировать. Мы опять получим
точно такой же лог ошибки, который был ранее. Обратите внимание
на то, как мы определяли функцию sum в языке C. Там мы явно
указали типы входных аргументов как int. Это означает, что
аргумент любого другого типа нельзя передать в эту функцию. Это
и называется статической типизацией. Также статическая типизация
обязывает указать тип для каждой переменной и запрещает менять
типы переменных после определения оных. То есть тип фиксирован,
статичен.
Вторая же группа языков называется динамически типизированными. Это такие языки как Python, Lua, JavaScript и другие. В них, соответственно, тип переменной строго не фиксирован и может меняться в ходе исполнения программы.
# Преимущества типизации
Пора перейти к вопросу, зачем типизация нам нужна, если в том же Python всё и так работает отлично. Во-первых, это скорость. На низком уровне (чем бы он ни был представлен) нам в любом случае нужно знать типы переменных. А тот факт, что мы можем позволить себе, их не выставлять, лишь означает, что кто-то делает это за нас (например, виртуальная машина), а это в свою очередь требует ресурсов. Отсюда и картина, которую мы наблюдаем в рейтингах языков по скорости:

Python в этом рейтинге, кстати говоря, на последнем месте. Может быть типизированный Python как раз поможет нам исправить этот печальный факт? К сожалению, нет. Поскольку Python как не был исконно, так по сей день остается, не статически типизированным языком. Аннотации типов в Python остаются опциональными, их можно не указывать. И сам интерпритатор Python в runtime не проверят их.
Тут мы переходим ко второму приемуществу, которое открывают для нас типы. И это качество кода.
«Цель типизации в Python — помочь инструментам разработки искать ошибки в кодовых базах на Python с помощью статического анализа, то есть не выполняя тестов кода.»
Лусиану Рамальо, «Python. К вершинам мастерства»
Расширяя эту мысль, мы можем уточнить, что цели типизации это:
- Раннее выявление ошибок — до runtime-а, до падения кода на продакшене
- “Экстракция” тестов — правильная типизация помогает уменьшить количество тестов, писать и поддерживать которые сложнее, чем типы. В тестах остаётся тестировать бизнес логику, а не банальное несоответствие примитивов.
- Улучшение читаемости кода — PEP 20 говорит нам о том, что “явное лучше неявного”. Когда мы читаем код, нам достаточно увидеть сигнатуру функции, не вчитываясь в её реализацию
- Упрощение разработки в IDE — больше подсказок и предупреждений о потенциальных ошибках.
- Повышение качества архитектуры — типы “заставляют” проектировать правильные абстракции.
# Как писать типизированно
Перед тем, как переходить к коду, давайте разберёмся с тремя важными понятиями: интерфейс, абстрактный класс и протокол. В чем разница? Давайте по порядку:
- Интерфейс — это класс, у которого все методы абстрактные, то есть не содержат деталей реализации.
- Абстрактный класс — это класс, у которого, помимо абстрактных методов, есть еще и реализованные методы.
- Протокол — это неявный интерфейс.
Первые два понятия должны быть понятны. А на последнем давайте остановимся. В Python реализация паттерна интерфейса работает через наследование, то есть, класс, реализующий интерфейс, наследуется от этого класса-интерфейса. При этом класс, который реализует протокол, не наследуется от него и вообще может о нем не знать.
## Примитивы
Давайте наконец посмотрим на то, как же писать типизированный код. Начнем с простейшего примера функции, которая мультиплицирует переданную ей строку $n$ раз.
def multi_string(string, n):
return string * n
print(multi_string("cat", 3)) # > catcatcatЭто простейшая функция, которая принимает на вход строку и число, а возвращает строку, полученную путём конкатенации исходной строки с самой собой $n$ раз. Давайте типизируем эту функцию!
def multi_string(string: str, n: int) -> str:
return string * nСинтаксис простой: типы для входных параметров мы подписываем через двоеточие, а выходной тип с помощью стрелочки. Теперь наши редакторы кода (IDE) будут подсвечивать для нас ошибку, в случае если мы неправильно передадим входные или неправильно обработаем выходные значения функции:

Таким образом можно проаннотировать все классические примитивы
— str, int, bytes, float, Decimal, bool.
## Объединение типов
В реальности, конечно, бывают и более сложные кейсы, когда функция может принимать и работать с разными типами, но все же не любыми. В таких случаях можно использовать объединение типов:
def normalize(data: str | bytes) -> str:
if isinstance(data, bytes):
return data.decode("utf-8")
return dataВ примере выше функция normalize принимает на вход либо строку,
либо последовательность байт и на выход всегда возвращает строку
в кодировке utf-8. Через union (|) можно перечислить любое
количество типов, но важно понимать, где это уместно. Если вам
хочется написать простыню из объединения десяти типов, стоит
хорошенько подумать. О том как такие ситуации разрешаются, мы
скажем ниже.
Объединение также можно использовать и в указании типа возвращаемого значения. Но использовать это стоит только для спецификации опциональности. Например:
def parse_int(value: str) -> int | None:
if not value.isdigit():
return None
return int(value)По сути, тут мы говорим, что результат функции опционален. Она
может вернуть int, либо не вернуть его, если что-то пошло не
так. Однако, аннотировать возвращаемое значение как int | str
или любым другим подобным образом, где мы просто объединяем два
совершенно разных типа, считается плохой практикой. Потому что
в таком случае непонятно, как обрабатывать результат этой
функции. В результате функции мы можем ожидать либо конкретную
реализацию, про которую нам точно известны её методы и атрибуты,
либо None. Отступление от этого, как правило, будет приводить
к усложнению кода.
## Спецификация коллекций
Помимо примитивов мы конечно же можем аннотировать и коллекции.
Но лучше не ограничиваться простым data: list, а специфицировать
и содержание коллекции тоже. Это можно сделать, используя
синтаксис квадратных скобок. Давайте приведём несколько примеров:
def format_user(user: tuple[str, int]) -> str:
name, score = user
return f"{name}: {score} points"
def average(values: list[float]) -> float:
if len(values) == 0:
return 0
return sum(values) / len(values)
def total_count(counters: dict[str, int]) -> int:
return sum(counters.values())Это, конечно, не покрывает все возможные сценарии, которые могут возникнуть в реальном продакшн коде. В секциях TypedDict, NamedTuple, Dataclass мы расширим наш инструментарий.
## Mapping и MutableMapping
Mapping и MutableMapping — это абстрактные классы для
словари-подобных структур. Mapping гарантирует только чтение
(ключи, значения, итерацию), а MutableMapping говорит, что
объект можно изменять.
from collections.abc import Mapping, MutableMapping
def read_config(cfg: Mapping[str, str]) -> str:
return cfg["DATABASE_URL"]
def patch_config(cfg: MutableMapping[str, str]) -> None:
cfg["DEBUG"] = "1"Если функция только читает данные, указывайте Mapping. Если
меняет — MutableMapping. Это маленькая, но важная подсказка для
читателя и статического анализатора.
## NamedTuple
Когда нужно описать структуру с фиксированным набором полей и
одновременно сохранить поведение кортежа, удобно использовать
NamedTuple. Это неизменяемый тип данных, который можно
индексировать, но при этом читать поля по именам.
from typing import NamedTuple
class User(NamedTuple):
id: int
username: str
score: int
def print_user(user: User) -> str:
return f"{user.username} ({user.id}) = {user.score}"NamedTuple хорошо подходит для компактных структур данных,
которые не должны изменяться после создания. Если вам нужны
изменяемые поля и более богатое поведение, лучше выбрать
dataclass или обычный класс.
## TypedDict
Если же ваша структура — это обычный словарь, но вы хотите
типизировать ожидаемые ключи и значения, используйте TypedDict.
Такой тип описывает форму словаря и работает только на уровне
статического анализа.
from typing import TypedDict
class User(TypedDict):
id: int
username: str
email: str | None
def send_email(user: User) -> None:
...Это особенно полезно, когда данные приходят из JSON или другого динамического источника, но вы хотите строгий контракт по ключам. Если часть ключей опциональна, опишите их явно, чтобы не терять проверку на уровне типов.
## Dataclass
dataclass — это удобный способ описать “контейнер данных” с
инициализатором, сравнениями и читаемым repr. Такой класс
изменяем, если не указать обратное, и отлично подходит для домена
или DTO.
from dataclasses import dataclass
@dataclass
class User:
id: int
username: str
email: str | None = None
def normalize(user: User) -> User:
user.username = user.username.lower()
return userdataclass хорош, когда нужна структура, которая может
изменяться, и когда важна ясная модель данных. Если объект
должен быть неизменяемым, используйте @dataclass(frozen=True).
## Enum
Enum помогает описать закрытый набор значений. Это полезно,
когда у поля есть строго ограниченный набор допустимых вариантов,
и вы хотите, чтобы типы не позволяли случайные строки.
from enum import Enum
class Status(Enum):
NEW = "new"
DONE = "done"
FAILED = "failed"
def is_done(status: Status) -> bool:
return status is Status.DONEТакой подход удобен для статусов, ролей, флагов, режимов работы, то есть любого “перечислимого” доменного значения.
## Кастомные классы
Конечно же система типов позволяет специфицировать не только примитивы, но и наши собственные или библиотечные классы. Например:
class User:
id: int
username: str
email: str
friends: list[User]
def hand_shake(user1: User, user2: User) -> None:
user1.friends.append(user2)
user2.friends.append(user1)## Абстрактные классы
В Python у нас существует прекрасный модуль collections.abc. Там
уже описан ряд абстрактных классов, которые в 90% случаев закроют
все наши потребности. Они полезны, когда вы хотите описывать
поведение, а не конкретную реализацию. Итак, что же там
представлено? А вот что:
collections.abc.ABCMeta
collections.abc.AsyncGenerator
collections.abc.AsyncIterable
collections.abc.AsyncIterator
collections.abc.Awaitable
collections.abc.Buffer
collections.abc.ByteString
collections.abc.Callable
collections.abc.Collection
collections.abc.Container
collections.abc.Coroutine
collections.abc.EllipsisType
collections.abc.FunctionType
collections.abc.Generator
collections.abc.GenericAlias
collections.abc.Hashable
collections.abc.ItemsView
collections.abc.Iterable
collections.abc.Iterator
collections.abc.KeysView
collections.abc.Mapping
collections.abc.MappingView
collections.abc.MutableMapping
collections.abc.MutableSequence
collections.abc.MutableSet
collections.abc.Reversible
collections.abc.Sequence
collections.abc.Set
collections.abc.Sized
collections.abc.ValuesViewКак мы видим тут большое количество абстрактных классов. Большинство из них созданы для описания какого-то свойства коллекции. Аннотируя с их помощью код, мы можем выражать более широкие полиморфные границы наших функций. Например:
from collections.abc import Iterable
def total(values: Iterable[int]) -> int:
return sum(values)
total([1, 2, 3])
total((1, 2, 3))
total({1, 2, 3})Иногда можно встретить такие же импорты из модуля typing:
from typing import Iterable, Sequence. Но в реальности это
просто реэкспорт реализаций из collections.abc. Поэтому сейчас
лучше импортировать абстрактные классы напрямую из
collections.abc.
## Sequence и Iterable
Эти два типа часто путают. Iterable гарантирует только то, что
объект можно перебирать в цикле. Никаких индексов, длины или
упорядоченности тут не обещается. Sequence же, кроме
возможности итерации, гарантирует наличие индексации и длины,
то есть __getitem__ и __len__. Из этого вытекает разница
в том, что можно безопасно делать с объектом.
from collections.abc import Iterable, Sequence
def sum_any(values: Iterable[int]) -> int:
total = 0
for v in values:
total += v
return total
def head(values: Sequence[int]) -> int:
return values[0]Функция sum_any примет и список, и кортеж, и генератор.
Функция head уже не сможет принять генератор, потому что у него
нет индексации, и мы не можем написать values[0]. Поэтому,
когда вам важно только итерироваться — используйте Iterable,
а если вы опираетесь на индексацию или длину — Sequence.
## Callable
Callable используется, когда функция принимает другую функцию.
Это особенно важно для коллбеков, обработчиков событий и функций
высшего порядка.
from collections.abc import Callable
def apply(values: list[int], fn: Callable[[int], int]) -> list[int]:
return [fn(v) for v in values]Если сигнатура заранее неизвестна, можно использовать
Callable[..., ReturnType], но это стоит делать как крайний
вариант.
## Дженерики (generics)
Generics — это параметризованные обобщённые типы. Проще говоря,
это типы, которые сами принимают типы. Это важно, когда вы хотите
сохранить связь между входом и выходом, а не потерять её в Any.
Например, функция first возвращает элемент того же типа, что и
внутри переданной коллекции:
from collections.abc import Sequence
from typing import TypeVar
T = TypeVar("T")
def first(items: Sequence[T]) -> T:
return items[0]Без дженериков нам пришлось бы писать Sequence[Any] и терять
тип результата. А с дженериками анализатор знает, что если мы
передали Sequence[str], то вернётся str. Это особенно важно
для коллекций, фабрик и репозиториев, где один и тот же код
обрабатывает разные типы.
Также полезно знать, что у TypeVar есть параметр bound,
который позволяет ограничить те типы, которые могут попадать
в дженерик:
from collections.abc import Hashable, Iterable
from typing import TypeVar
HashableT = TypeVar("HashableT", bound=Hashable)
def mode(data: Iterable[HashableT]) -> HashableT:
...## Literal
Literal позволяет зафиксировать конкретные допустимые значения,
а не просто базовый тип. Это полезно, когда у параметра есть
закрытый набор режимов, статусов или ключей.
from typing import Literal
def export_report(format: Literal["csv", "json"]) -> bytes:
...Сигнатура выше сразу задаёт контракт: третьего формата тут нет.
Это делает API понятнее и позволяет анализатору ловить опечатки
вроде "jsno" до запуска.
## Статические анализаторы
И наконец пришло время познакомиться с тем, кто “оживляет” типизацию в Python: со статическими анализаторами. Они читают аннотации, сопоставляют их с кодом и подсвечивают ошибки до runtime.
mypy- классический статический анализатор типов для постепенного внедрения типизации. Сильная сторона: экосистема плагинов и тонкая настройка строгости по модулям. Слабая сторона: без настройки может быть либо слишком мягким, либо слишком шумным.pyright- быстрый и понятный чекер. Сильная сторона: хорошие диагностические сообщения и быстрый feedback. Слабая сторона: расширяемость через плагины хуже, чем вmypy.pyrefly- новый быстрый анализатор на Rust. Сильная сторона: высокая скорость и интеграция с LSP. Слабая сторона: проект ещё молодой, поэтому часть поведения может меняться.ty- новый Rust-инструмент от Astral (пока beta). Сильная сторона: скорость и современная архитектура. Слабая сторона: pre-release стадия, часть возможностей ещё догоняет зрелые чекеры.
Установить и запускать их можно через uv:
uv tool install mypy
uv tool install pyright
uv tool install pyrefly
uv tool install tyuvx mypy .
uvx pyright .
uvx pyrefly check
uvx ty checkНиже бизнес-пример с намеренными ошибками типизации:
from dataclasses import dataclass
from typing import NewType
UserId = NewType("UserId", int)
@dataclass
class User:
id: UserId
email: str
is_active: bool
def discount(total: int, percent: int) -> int:
return total - total * (percent / 100)
def send_invoice(user: User, amount: int) -> str:
if not user.is_active:
return None
return f"invoice for {user.email}: {amount}"
def main() -> None:
user = User(id=42, email=123, is_active="yes")
total = discount("1000", 10)
send_invoice(user, "500")Проверка pyright даст сообщения примерно такого вида:
error: Type "float" is not assignable to return type "int"
error: Type "None" is not assignable to return type "str"
error: "Literal[42]" is not assignable to "UserId"
error: "Literal[123]" is not assignable to "str"
error: "Literal['yes']" is not assignable to "bool"
error: "Literal['1000']" is not assignable to "int"
error: "Literal['500']" is not assignable to "int"Что важно в этом выводе:
- Ошибки в
discountиsend_invoiceпоказывают нарушение контракта функции: сигнатура обещает одно, реализация делает другое. - Ошибки в
mainпоказывают, что граничный слой (ввод/DTO) передаёт неверные типы в доменную логику. - Сообщение про
UserIdдемонстрирует, зачемNewTypeполезен в бизнес-коде:idпользователя нельзя случайно подменить обычнымintбез явного решения разработчика.
## Stub файлы (.pyi)
Иногда хочется типизировать код, к которому нельзя или неудобно
вносить изменения. Например, это сгенерированный код, код
сторонней библиотеки или даже ваш собственный модуль, где вы не
хотите мешать типы с реализацией. Для этого существуют stub
файлы — файлы с расширением .pyi.
Файл .pyi содержит только типовые сигнатуры и не содержит
реализации. Статические анализаторы ищут их рядом с кодом или в
отдельных пакетах types-*. Пример:
calc.py:
def add(a, b):
return a + bcalc.pyi:
def add(a: int, b: int) -> int: ...Так вы можете поддерживать типизацию отдельно от реализации, а иногда даже без доступа к исходникам.
## TypeAlias
Когда тип становится сложным, его лучше вынести в алиас, чтобы
сигнатуры были читаемыми. Это особенно полезно для бизнес-терминов
вроде UserId, Currency, Payload. Для этого используют
TypeAlias:
from typing import TypeAlias
UserId: TypeAlias = int
Payload: TypeAlias = dict[str, str | int | float]Теперь можно писать:
def send(user_id: UserId, payload: Payload) -> None:
...В Python 3.12+ для того же есть синтаксис type:
type UserId = int
type Payload = dict[str, str | int | float]Это всё тот же алиас, просто короче и читабельнее.
## TypeAlias vs NewType
TypeAlias — это просто синоним типа, он не создаёт новый тип.
NewType же создаёт новый тип на уровне статической проверки,
хотя на runtime это всего лишь функция-обёртка. Это полезно,
когда у вас есть логически разные значения одного базового типа,
например UserId и OrderId.
from typing import NewType, TypeAlias
UserId: TypeAlias = int
OrderId = NewType("OrderId", int)
def get_user(user_id: UserId) -> None:
...
def get_order(order_id: OrderId) -> None:
...UserId и int считаются одним и тем же типом, а вот OrderId
уже не совместим с int без явного приведения.
# Как правильно использовать типизацию
Сразу хочу сформировать у вас верную предпосылку относительно типизации. Точно так же как и у тестов, задача у типов падать, не проходить, крашиться и бесить этим нас. Если они этого не делают, их можно просто выкинуть. Это означает, что чем менее снисходительна к нам система типизации, тем лучше для нас. Потому что это заставляет нас думать о том, как писать надёжный и безопасный код. Строгость системы типизации (как и у тестов) помогает выявлять ошибки, но для этого они должны не проходить.
И да, писать действительно корректно типизированный код сложно. Это отдельный навык, который развивается так же, как архитектура или тестирование. Поэтому нормально начинать с малого и постепенно усиливать строгость.
##
Возвращение None
Также обратите внимание на случай, когда функция ничего не возвращает:
def print_weather(weather: Weather):
print("Weather:")
for date, data in weather.by_days().items():
print(date)
print(f"\t{data.temperature}")
print(f"\t{data.humidity}")
print(f"\t{data.wind_speed}")
print("========")
tmp = print_weather(Weather()) + 1Как известно в Python, если в функции не стоит return, значит
она ничего не возвращает. Об этом знают и статические анализаторы.
Например, pyright для этого кода, где намеренно в последней
строчке допущена ошибка, даст следующее предупреждение:
error:
Operator "+" not supported for types "None" and "Literal[1]"По этому сообщению становится ясно, что pyright “понимает”, что
возвращаемый тип функции None. Так значит можно их не
подписывать? Нет, не значит. Во-первых, тут можно просто
сослаться на PEP 20, “явное
лучше неявного”. Во-вторых, стоит учесть специфику
developer experience (DX) при разработке на Python. Все, кто пишут на
Python, знают, что типизация опциональна. И это создаёт
двусмысленность: когда я смотрю на сигнатуру функции, где не
специфицировано возвращаемое значение, я не понимаю, это автор
кода просто не стал её типизировать и на самом деле она возвращает
не None, либо там действительно возвращается None.
Разрешается эта двусмысленность только погружением в детали
реализации функции, что усложняет работу с кодом.
## Вход и выход у функции
Продолжим тему возвращаемых значений. Не стоит прибегать
к использованию абстрактных классов из модуля collections.abc,
а также реализованных самостоятельно, для указания возвращаемого
значения из функции. Они предназначены для входных значений,
чтобы сохранить полиморфность функции в максимально широких
границах. Возвращаемое значение должно быть специфицировано
конкретной реализацией, чтобы было ясно, как обрабатывать
возвращаемый результат.
ImportantФункция должна более ясно говорить о том, какой конкретный тип она возвращает, чем принимает.
Если вы хотите углубиться в тему, ниже — отличные материалы, которые помогут настроить инструменты и разобраться в деталях типовой системы Python.
- Типизированный Python для профессиональной разработчки
- FastAPI type hints guide (helpful for web developers)
- RealPython, Type Checking Guide