Type hints: typing, mypy, Optional, Union

Python динамически типизированный - переменные не объявляют тип заранее. Но в больших проектах это создаёт проблемы: невозможно понять что функция принимает и возвращает, IDE не подсказывает методы, ошибки находятся только в runtime. С Python 3.5 в язык добавлены type hints - декларативные аннотации типов, которые не влияют на исполнение, но проверяются статическими анализаторами (mypy, pyright). Это превращает Python в гибрид динамики и статической безопасности.

Базовые аннотации

Аннотация типа пишется после двоеточия:

def greet(name: str) -> str:
    return f"Привет, {name}!"

age: int = 30
prices: list = [99.99, 19.99]

Аннотации не влияют на runtime - Python всё равно динамичен:

def greet(name: str) -> str:
    return f"Привет, {name}!"

greet(42)  # работает! вернёт 'Привет, 42!'

Никакого исключения не будет. Анотации - только для документации и статической проверки.

Базовые типы

n: int = 5
x: float = 3.14
flag: bool = True
name: str = "Alice"
data: bytes = b"binary"
nothing: None = None

Подсказки совпадают с типами Python без специальных импортов.

Контейнеры с типизацией

С Python 3.9 можно использовать встроенные типы как generic:

numbers: list[int] = [1, 2, 3]
prices: dict[str, float] = {"apple": 99.99}
points: tuple[int, int] = (10, 20)
ids: set[int] = {1, 2, 3}

В Python 3.8 и старее нужно из typing:

from typing import List, Dict, Tuple, Set

numbers: List[int] = [1, 2, 3]
prices: Dict[str, float] = {"apple": 99.99}

В 2026 году все проекты на Python 3.11+, поэтому используй встроенные list[int], dict[str, int] - они короче и не требуют импорта.

Optional и Union

Optional[X] - значит «X или None». Почему None стоит отличать от пустой строки и нуля, разбирали в уроке про truthy/falsy:

from typing import Optional

def find_user(id: int) -> Optional[str]:
    if id == 1:
        return "Alice"
    return None

С Python 3.10 появился короткий синтаксис X | None:

def find_user(id: int) -> str | None:
    ...

Union[X, Y] - «X или Y»:

from typing import Union

def parse_id(value: Union[str, int]) -> int:
    return int(value)

# Python 3.10+
def parse_id(value: str | int) -> int:
    return int(value)

Синтаксис с | короче и читается естественнее - используй его.

`Optional[str]` - это тип. Параметр с дефолтом `None` - синтаксическая отдельная вещь.
def f(x: Optional[str] = None):  # тип допускает None, есть дефолт
def f(x: str | None = None):      # то же самое
def f(x: str = None):              # тип str не допускает None - mypy ругается

Callable, генераторы и итерации

Для функций:

from typing import Callable

def apply(f: Callable[[int, int], int], a: int, b: int) -> int:
    return f(a, b)

apply(lambda x, y: x + y, 1, 2)  # 3

Callable[[arg_types], return_type]. Первый параметр - список типов аргументов, второй - тип результата.

Для итераторов:

from typing import Iterator, Generator

def numbers() -> Iterator[int]:
    for i in range(10):
        yield i

# Generator более подробно (с yield/send/return)
def generator() -> Generator[int, None, None]:
    yield 1

Чаще достаточно Iterator или Iterable - как они работают под капотом, в уроке про генераторы.

Любой тип: Any

Any отключает проверку:

from typing import Any

def process(data: Any) -> Any:
    return data

Использовать с осторожностью: Any убирает все гарантии. Лучше использовать object если ты явно не знаешь тип, но хочешь хотя бы поверхностную проверку.

Aliases - именованные типы

Длинные типы можно вынести в alias:

from typing import TypeAlias

UserId: TypeAlias = int
UserData: TypeAlias = dict[str, str | int]

def get_user(uid: UserId) -> UserData:
    ...

С Python 3.12 есть синтаксис без TypeAlias:

type UserId = int
type UserData = dict[str, str | int]

Литеральные типы

Если параметр принимает только конкретные значения:

from typing import Literal

def set_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    ...

set_level("debug")   # OK
set_level("trace")   # mypy ошибка

Это полезнее чем строка str - анализатор поймает опечатки.

Final - неперезаписываемые имена

from typing import Final

MAX_RETRIES: Final = 3
MAX_RETRIES = 5  # mypy ошибка

Не превращает в константу в runtime (Python такого не умеет), но даёт сигнал анализатору и читателю.

TypedDict - типизированные словари

Если dict используется как «структура с известными ключами»:

from typing import TypedDict

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

def create_user(data: User) -> None:
    print(data["name"])

user: User = {"id": 1, "name": "Alice", "email": "a@b.c"}

Альтернатива - dataclass или Pydantic, обычно гибче.

Generic-функции

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

first([1, 2, 3])     # T = int
first(["a", "b"])    # T = str

С Python 3.12 короче:

def first[T](items: list[T]) -> T:
    return items[0]

Protocols (structural subtyping)

Утиная типизация с проверкой:

from typing import Protocol

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

def safe_close(resource: Closable) -> None:
    resource.close()

# Не требуется наследование - достаточно метода close()
class File:
    def close(self) -> None:
        print("closed")

safe_close(File())  # OK

В отличие от ABC (явное наследование), Protocol проверяет наличие методов структурно. Полное сравнение - в уроке про ABC и Protocol.

Запуск mypy

pip install mypy
mypy myscript.py

mypy найдёт типичные ошибки:

def greet(name: str) -> str:
    return f"Hello, {name}!"

greet(42)  # mypy: Argument 1 to "greet" has incompatible type "int"; expected "str"

В реальных проектах mypy запускают в CI - падение типов блокирует мерж. Как собрать связку ruff + mypy, смотри в уроке про линтеры и типы.

mypy не ловит все ошибки. Type hints - помощь, не гарантия. Кому-то всё равно можно передать `Any` или сделать `cast`. На сложном dynamic-коде mypy может сдаться. Это инструмент, который ловит **большой класс ошибок дёшево**, но не заменяет тесты и code review.

Когда писать аннотации

Обязательно:

  • Публичные API (функции, методы классов которые вызываются снаружи)
  • Параметры функций, которые видны другим разработчикам
  • Возвращаемые значения публичных функций

Желательно:

  • Сложные локальные переменные (где тип не очевиден)
  • Функции с >3 параметрами

Можно опустить:

  • Очевидные локальные переменные (x = 5 явно int)
  • Простые скрипты «на один раз»
  • Лямбды и короткие helper-функции

Современная конвенция: типизировать максимум, оставляя локальные очевидности без аннотаций.

Postponed evaluation

Если используешь сам класс в его же аннотациях:

class Node:
    def __init__(self, value: int, next: Optional["Node"] = None):
        ...

Кавычки нужны потому что класс ещё не определён в момент чтения аннотации. С Python 3.10+ можно from __future__ import annotations, и кавычки не нужны:

from __future__ import annotations

class Node:
    def __init__(self, value: int, next: Optional[Node] = None):
        ...

В Python 3.13+ это станет поведением по умолчанию.

Мини-задание

  1. Аннотируй функцию:
def find_user(users, email):
    for user in users:
        if user["email"] == email:
            return user
    return None

# Превратить в:
def find_user(users: list[dict[str, str]], email: str) -> dict[str, str] | None:
    ...
  1. Установи mypy и проверь файл:
pip install mypy
echo 'def greet(name: str) -> str: return name + 1' > test_types.py
mypy test_types.py
# error: Unsupported operand types for + ("str" and "int")
  1. Используй Literal для ограничения значений:
from typing import Literal

LogLevel = Literal["debug", "info", "warning", "error"]

def log(message: str, level: LogLevel = "info") -> None:
    print(f"[{level.upper()}] {message}")

log("test")           # OK
log("test", "trace")  # mypy: Argument 2 to "log" has incompatible type

Что дальше

Модуль 2 завершён - разобрались с типами данных, изменчивостью и аннотациями. В следующем модуле перейдём к управлению потоком: условия, циклы, исключения, match-case и context managers.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.