Зачем junior’у вообще нужны типы
В уроке 06 второго модуля мы уже видели функции с аннотациями: def calculate_total(price: float, quantity: int) -> float. Там было правило: «каждый аргумент аннотируется, возвращаемое значение через ->». Сейчас разберёмся, почему это правило существует и что ещё умеют типы.
Python — динамический язык. Это значит, что переменная не имеет фиксированного типа: x = 1, через строчку x = "hello" — нормально. Интерпретатор не проверяет совместимость типов на этапе компиляции, как Java или C#.
Боль выглядит так. Вы открываете чужой ETL и видите функцию process(records, config, mode). Что такое records — список словарей? Iterator из CSV-парсера? DataFrame? Что в словаре — какие ключи? Что такое mode — строка "append"/"overwrite" или целое число? Без типов узнать можно только одним способом: прочитать всё тело функции и все её вызовы. На крупном проекте это занимает дни.
def f(x: int) спокойно примет строку. Но три бонуса всё равно работают:
- IDE автодополнение и подсказки. PyCharm и VS Code по аннотациям понимают, что у
records: list[dict[str, str]]каждый элемент — словарь, и предлагают.get,.keys, итерацию по парам. - . mypy/pyright ловит «передал str где ждали int» до runtime. Это значит ошибки находятся на CI, а не у клиента.Статическая проверка
- Документация. Сигнатура
def parse(raw: dict[str, str]) -> Record | Noneбез слов рассказывает, что на вход словарь со строковыми значениями, на выход — либо запись, либо None.
В этом курсе мы пишем type hints всегда, для всех функций и всех методов. Это не «опциональная фича для энтерпрайза», а норма современной Python-разработки. На code review вас попросят добавить аннотации, если их не будет.
Эволюция типов: от PEP 484 до сегодня
Чтобы понимать, почему синтаксис типов выглядит именно так, нужна минимальная история.
- PEP 484 (2014, Python 3.5). Появились type hints как самостоятельный синтаксис. Тогда же родился модуль
typingс типамиList,Dict,Optional,Union. Писали так:def f(xs: List[int]) -> Optional[str]. - PEP 585 (2019, Python 3.9). Встроенные коллекции
list,dict,tuple,setсами стали generic-типами. Можноlist[int]вместоList[int].from typing import Listстало не нужно для базовых случаев. - PEP 604 (2020, Python 3.10). Появился синтаксис
X | YвместоUnion[X, Y]иX | NoneвместоOptional[X]. Гораздо короче. - PEP 673 (2022, Python 3.11).
Selfтип для методов, которые возвращаютself. - PEP 695 (2023, Python 3.12). Новый синтаксис для type aliases и generics:
type Vector = list[float],class Box[T]: ...— безTypeVar. - PEP 749 / 749-like (2025, Python 3.14). по умолчанию. До 3.14 для этого нужен былОтложенные аннотации
from __future__ import annotations.
Главный практический вывод: с Python 3.13 пишите современный синтаксис. Старая школа с from typing import List, Optional встречается только в legacy-кодовой базе или в туториалах 2018 года. Новый код выглядит так:
def first_email(users: list[dict[str, str]]) -> str | None:
for user in users:
if email := user.get("email"):
return email
return None
Никаких List, Dict, Optional. Всё через встроенные типы и |.
Базовые аннотации
Минимальный словарь, который покрывает 80% задач junior DE.
# скаляры
age: int = 30
name: str = "Анна"
ratio: float = 0.95
active: bool = True
nothing: None = None
# коллекции — generic'и через []
ids: list[int] = [1, 2, 3]
config: dict[str, str] = {"host": "localhost", "port": "5432"}
unique_tags: set[str] = {"prod", "etl"}
coords: tuple[float, float] = (55.75, 37.62)
# tuple переменной длины
row: tuple[int, ...] = (1, 2, 3, 4, 5)
Обратите внимание на tuple — у него два варианта аннотации:
tuple[int, str, float]— кортеж ровно трёх элементов фиксированных типов. Как именованная запись.tuple[int, ...]— кортеж любой длины одного типа. Используется реже.
Можно вкладывать:
records: list[dict[str, int | str]] = [
{"id": 1, "name": "Анна"},
{"id": 2, "name": "Борис"},
]
«Список словарей, в которых ключ — строка, а значение — либо int, либо str». Читается как фраза.
Union и Optional через |
В DE постоянно встречаются «либо то, либо это»: значение может быть числом или строкой, поле может быть заполнено или отсутствовать. Это
# либо int, либо None (поле может отсутствовать)
age: int | None = None
# либо int, либо str (например, id может быть числом или UUID-строкой)
record_id: int | str = "abc-123"
# чаще двух тоже бывает
value: int | float | str | None = None
«Что-то-или-None» — настолько частый случай, что у него было своё имя Optional[X]. Сейчас пишут X | None. Эквивалентно, но короче и читается естественнее.
Optional[int] означает int | None, не «опциональный аргумент функции». Это про тип значения, а не про обязательность параметра. У функции def f(x: int | None = None) параметр x опциональный из-за = None в default, а не из-за | None в типе.
Аннотации функций — повторим
Из урока 06:
def parse_row(
row: dict[str, str],
*,
strict: bool = False,
) -> dict[str, int | str | None]:
"""Парсит сырую строку CSV в типизированную запись."""
...
Правила:
- Каждый параметр аннотируется через
:. - Возвращаемое значение — через
->. - Если функция ничего не возвращает —
-> None. - Если функция генератор —
-> Generator[YieldType, SendType, ReturnType]или-> Iterator[YieldType](см. модуль 4).
Когда у функции много аргументов и сигнатура не помещается в одну строку — разбиваем построчно, как в примере. Это норма для DE-функций.
Any, Never, Self — три специальных типа
from typing import Any, Never, Self
Any — «любой тип, не проверяй». Иногда нужен, когда тип реально неизвестен или мы говорим type checker’у «отстань».
def from_json(raw: str) -> Any:
"""Парсит JSON. Структура заранее неизвестна."""
import json
return json.loads(raw)
Any — это
Any в проекте, тем меньше пользы от типов вообще.
Never — «эта точка кода недостижима». Полезно для функций, которые всегда падают:
def fail(message: str) -> Never:
raise RuntimeError(message)
mypy после такого вызова знает, что дальше код не выполнится.
Self (Python 3.11+) — «тип этого же класса». Нужен для методов, возвращающих self, особенно в наследовании:
class QueryBuilder:
def __init__(self) -> None:
self.parts: list[str] = []
def where(self, condition: str) -> Self:
self.parts.append(condition)
return self
До 3.11 пришлось бы писать -> "QueryBuilder" (строка-forward reference) или -> QueryBuilder с from __future__ import annotations. С Self это решено элегантно.
Literal — строка только из перечисленного
В DE-коде часто встречается «один из нескольких режимов»: mode="append" | "overwrite" | "error". Можно типизировать как str, но точнее — Literal:
from typing import Literal
WriteMode = Literal["append", "overwrite", "error"]
def write_partition(path: str, df: object, mode: WriteMode = "error") -> None:
if mode == "append":
...
elif mode == "overwrite":
...
elif mode == "error":
...
Теперь type checker не пустит write_partition(path, df, mode="appned") — опечатка отловится статически. И IDE предложит автодополнение из трёх вариантов.
TypedDict — словарь с известной схемой
DE постоянно работает с JSON: ответы API, конфиги, сообщения из Kafka. JSON в Python — это dict[str, Any]. Но обычно структура известна: знаем, что у пользователя есть id, name, email. Это и есть случай
from typing import TypedDict
class GithubUser(TypedDict):
id: int
login: str
email: str | None
followers: int
def display_user(user: GithubUser) -> str:
return f"{user['login']} ({user['followers']} followers)"
GithubUser — обычный словарь в runtime. Никаких новых классов в памяти. Но статически type checker знает, что user["id"] это int, а user["unknown_key"] — ошибка.
Когда поле может отсутствовать, есть NotRequired:
from typing import TypedDict, NotRequired
class HttpResponse(TypedDict):
status: int
body: str
headers: NotRequired[dict[str, str]] # может быть, а может и нет
TypedDict — идеальный инструмент для типизации JSON-ответов до того, как вы переведёте код на Pydantic (об этом в уроке 03 этого модуля). Промежуточный шаг между «всё dict» и «строгая валидация».
Final и ClassVar базово
Иногда хочется сказать «эта переменная не должна переприсваиваться»:
from typing import Final
MAX_BATCH_SIZE: Final = 1000
# дальше в коде
MAX_BATCH_SIZE = 2000 # type checker ругнётся
Final — это «константа на уровне типа». В runtime ничего не меняется, но статически переприсваивание ловится.
ClassVar нужен реже, для атрибутов класса, общих для всех экземпляров:
from typing import ClassVar
class Connection:
DEFAULT_TIMEOUT: ClassVar[int] = 30 # общий для всех Connection
def __init__(self, host: str) -> None:
self.host = host # уникален для каждого экземпляра
Пока запомните, что они есть. Подробнее Final будет полезен в Module 06 для констант конфига.
from future import annotations и PEP 649
Бывает, нужно сослаться на класс, который объявлен ниже в файле:
class Tree:
def __init__(self, value: int, left: Tree | None = None) -> None:
...
В Python 3.13 этот код упадёт на парсинге: на момент определения метода имя Tree ещё не существует. Старый трюк — строка-forward reference: left: "Tree" | None. Современный способ — отложить вычисление всех аннотаций:
from __future__ import annotations
class Tree:
def __init__(self, value: int, left: Tree | None = None) -> None:
...
С этим импортом все аннотации в файле становятся строками — они не вычисляются при загрузке модуля. Вычисляются только когда кому-то понадобится (например, mypy или typing.get_type_hints).
В Python 3.14 появится
from __future__ import annotations — рабочий приём, и его часто видят в production-коде.
Junior-практика: если у вас Python 3.13 и нужна forward reference (на класс, объявленный ниже), просто добавьте from __future__ import annotations в начале файла. Решает все проблемы forward references без боли.
DE-кейс: типизированный ETL-степ
Соберём всё вместе на типовой задаче — один шаг ETL, который читает сырые записи и возвращает чистые.
from typing import Literal, TypedDict
class RawOrder(TypedDict):
"""Запись из источника — всё строки, как из CSV."""
order_id: str
amount: str
currency: str
status: str
class CleanOrder(TypedDict):
"""Запись после нормализации, типы приведены."""
order_id: int
amount_cents: int
currency: Literal["USD", "EUR", "RUB"]
status: Literal["created", "paid", "cancelled"]
def normalize_order(raw: RawOrder) -> CleanOrder | None:
"""Нормализует одну запись заказа.
Returns None если запись битая (нечисловой amount, неизвестный currency и т.п.).
"""
try:
order_id = int(raw["order_id"])
amount_cents = int(float(raw["amount"]) * 100)
except (ValueError, KeyError):
return None
currency = raw["currency"].upper()
if currency not in ("USD", "EUR", "RUB"):
return None
status = raw["status"].lower()
if status not in ("created", "paid", "cancelled"):
return None
return CleanOrder(
order_id=order_id,
amount_cents=amount_cents,
currency=currency, # type: ignore[typeddict-item]
status=status, # type: ignore[typeddict-item]
)
Что здесь сделано правильно:
- Два TypedDict —
RawOrderдля входа иCleanOrderдля выхода. Чётко видна трансформация. - Возвращаемый тип
CleanOrder | None— функция явно сообщает, что может вернуть None при невалидной записи. Literalсужает строковые поля до конкретных значений. После такой типизации type checker гарантирует, что вCleanOrderуже не попадётcurrency="ZWL".# type: ignore— намеренный escape для случаев, где type checker не может вывести сужение типа после runtime-проверки. Подробнее об ignore-комментариях — в уроке 04 этого модуля.
В следующем уроке мы посмотрим на dataclasses — способ заменить TypedDict, когда нужны не только данные, но и поведение, и неизменяемость.
Что должно остаться в голове
- Типы в Python — подсказки, не runtime-проверки. Их видит IDE, type checker, человек.
- Современный синтаксис:
list[int],dict[str, int],int | None. Безfrom typing import List, Optional. Any— escape hatch, использовать редко.Self— для методов, возвращающихself.Literal["a", "b"]— сужает строки до конкретных значений.TypedDict— типизированный словарь, идеален для JSON-ответов API.from __future__ import annotations— спасает от forward reference, до Python 3.14.