Learning Platform
Урок 06.01 · 20 мин
Начальный
Type hintsAnnotationsTypedDictPEP 484PEP 604
Type hints: PEP 585 / PEP 604 и modern syntax JSON deep: типы, числа, Unicode и парсинг в Python

Зачем junior’у вообще нужны типы

В уроке 06 второго модуля мы уже видели функции с аннотациями: def calculate_total(price: float, quantity: int) -> float. Там было правило: «каждый аргумент аннотируется, возвращаемое значение через ->». Сейчас разберёмся, почему это правило существует и что ещё умеют типы.

Python — динамический язык. Это значит, что переменная не имеет фиксированного типа: x = 1, через строчку x = "hello" — нормально. Интерпретатор не проверяет совместимость типов на этапе компиляции, как Java или C#.

Duck typing
— главная фишка Python и одновременно его главная боль на проектах больше 1000 строк.

Боль выглядит так. Вы открываете чужой ETL и видите функцию process(records, config, mode). Что такое records — список словарей? Iterator из CSV-парсера? DataFrame? Что в словаре — какие ключи? Что такое mode — строка "append"/"overwrite" или целое число? Без типов узнать можно только одним способом: прочитать всё тело функции и все её вызовы. На крупном проекте это занимает дни.

Type hints
решают эту проблему. Это подсказки, которые пишутся в той же сигнатуре функции и читаются машиной — IDE, статическим анализатором, человеком. На runtime они не делают ничего полезного: Python всё ещё динамический, и def f(x: int) спокойно примет строку. Но три бонуса всё равно работают:

  1. IDE автодополнение и подсказки. PyCharm и VS Code по аннотациям понимают, что у records: list[dict[str, str]] каждый элемент — словарь, и предлагают .get, .keys, итерацию по парам.
  2. Статическая проверка
    .
    mypy/pyright ловит «передал str где ждали int» до runtime. Это значит ошибки находятся на CI, а не у клиента.
  3. Документация. Сигнатура 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 постоянно встречаются «либо то, либо это»: значение может быть числом или строкой, поле может быть заполнено или отсутствовать. Это

union types
.

# либо 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. Эквивалентно, но короче и читается естественнее.

WARNING

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 — это

escape hatch
. Старайтесь использовать редко: чем больше 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. Это и есть случай

TypedDict
:

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 появится

PEP 649
, который делает это поведение дефолтом. До тех пор from __future__ import annotations — рабочий приём, и его часто видят в production-коде.

TIP

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, когда нужны не только данные, но и поведение, и неизменяемость.

Что должно остаться в голове

  1. Типы в Python — подсказки, не runtime-проверки. Их видит IDE, type checker, человек.
  2. Современный синтаксис: list[int], dict[str, int], int | None. Без from typing import List, Optional.
  3. Any — escape hatch, использовать редко. Self — для методов, возвращающих self.
  4. Literal["a", "b"] — сужает строки до конкретных значений.
  5. TypedDict — типизированный словарь, идеален для JSON-ответов API.
  6. from __future__ import annotations — спасает от forward reference, до Python 3.14.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4