The post has been translated automatically. Original language: Russian
In Python, typing often causes two extreme reactions.
Some say: Python is dynamic, types only spoil readability. Others turn each function into such a construct that the business logic is lost behind TypeVar, Protocol, Generic, Callable, Literal, TypedDict, and three layers of aliases.
Both extremes are not very useful.
Python typing is not good when the code looks like it does in Java. And not when every temporary dictionary is covered with types. It's good when it helps you understand the code faster, change it more safely, and catch errors before production.
The main thing is not to treat types as a religion. It's a tool.
The main idea
Typing in Python should not be a decoration. It should answer a practical question.:
what error does this type hint help to catch, or what thought about the code makes obvious?
If the answer is unclear, perhaps the type is not needed here. If the answer is obvious, typing starts to pay off very quickly.
Important: Python has not become a static language
The first thing to take into account is that type hints in Python by themselves do not force runtime to check values.
def double(x: int) -> int:
return x * 2
print(double("ha"))This code will not crash because of the x:int annotation. It will execute and print haha.
Types in Python are primarily needed by tools:
- type checker;
- IDE;
- linter;
- refactoring tools;
- autocomplete;
- code review;
- documentation for people.
And that's okay. Python remains a dynamic language, but gets a layer of static analysis where it helps.
Where typing gives the maximum benefit
Types are especially useful at borders.
1. The boundary of the function
If a function accepts and returns non-obvious structures, an annotation is almost always useful.
Badly:
def build_report(data, options):
...Better:
def build_report(
data: list[Order],
options: ReportOptions,
) -> Report:
...Now you can see not only what the function accepts, but also what level of abstraction is expected.
2. The boundary of the module
Everything can be clear inside a small function. But the public API of the module must be readable.
def send_invoice(invoice_id: InvoiceId, recipient: Email) -> SendResult:
...Such a signature explains the intention itself.
3. The boundary with external data
JSON, API responses, configs, and queued payloads are all high—risk areas.
Bad option:
def handle_event(event: dict) -> None:
user_id = event["user"]["id"]
...It's better this way at least.:
from typing import TypedDict
class UserPayload(TypedDict):
id: int
email: str
class EventPayload(TypedDict):
type: str
user: UserPayload
def handle_event(event: EventPayload) -> None:
user_id = event["user"]["id"]
...TypedDict does not turn a dictionary into a dataclass and does not validate data by itself. But it documents the expected form of the dictionary and helps the static analyzer to catch errors in keys and value types.
Don't type the obvious for show
There is a code where the annotation adds almost nothing.:
count: int = 0
name: str = "Alice"
active: bool = TrueSometimes this is justified, for example, if the variable then gets a value from different branches. But often it's just noise.
Typing should reduce cognitive load, not increase it.
Compare:
def normalize_email(value: str) -> str:
return value.strip().lower()Types are useful here: the function is public, and its contract is obvious.
But that's debatable.:
def normalize_email(value: str) -> str:
cleaned: str = value.strip()
lowered: str = cleaned.lower()
return loweredInternal variables do not always need to be annotated. Let the type checker do the type inference where it can handle it anyway.
Any is not evil, but a quarantine zone.
Many people are afraid of Any because it breaks typing. And it's true: Any actually tells type checker to trust me.
But in real Python code, Any is sometimes unavoidable.:
- old libraries without types;
- dynamic JSON;
- integration with external APIs;
- migration of a large project;
- monkey patching;
- plugins;
- ORM and metaprogramming.
The problem is not with Any One. The problem is that it's spreading.
It is a good practice to keep Any on the border and turn it into a normal structure as soon as possible.
Badly:
def process(payload: Any) -> None:
user_id = payload["user"]["id"]
send_email(payload["user"]["email"])Better:
def parse_event(payload: Any) -> EventPayload:
# there may be
a return payload runtime validation here
def process(event: EventPayload) -> None:
user_id = event["user"]["id"]
send_email(event["user"]["email"])The idea is simple: the outside world can be chaotic. But inside the app, the chaos shouldn't spread indefinitely.
TypedDict — for dictionaries that you cannot replace with a model
TypedDict is especially useful where a dictionary really remains a dictionary.:
- JSON payload;
- response from an external API;
- configuration;
- event from the queue;
- legacy code;
- a lightweight DTO without a runtime model.
But if the structure lives for a long time inside your domain, it is often better to use a dataclass, a Pydantic model, or a regular class.
For example, for incoming JSON:
class GithubUserPayload(TypedDict):
id: int
login: str
avatar_url: strAnd for the domain entity:
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
id: int
login: str
avatar_url: strSo the code honestly divides the two worlds.:
- external dictionary;
- the internal model.
This is better than dragging dict[str, Any] through half of the application.
Protocol — typing without unnecessary inheritance
One of the most pythonic typing tools is Protocol.
It allows you to describe not "who you are," but "what you can do."
For example, a function doesn't care which specific object was passed to it. It is important to her that the object has a send method.
from typing import Protocol
class Sender(Protocol):
def send(self, message: str) -> None:
...
def notify(sender: Sender, text: str) -> None:
sender.send(text)Now any object with the send(message:str) -> None method fits this contract. It does not need to be explicitly inherited from Sender.
This fits well with the spirit of Python: duck typing remains, but gets static validation.
This approach is especially useful for:
- tests;
- adapters;
- dependency injection;
- ports and interfaces;
- code that should not depend on a specific implementation.
NewType helps to avoid confusing the same basic types.
A very common mistake: everything is int or str, but the meaning is different.
def get_order(user_id: int, order_id: int) -> Order:
...Technically, it's easy to confuse arguments in places.:
get_order(order_id, user_id)Both values are int, runtime will not be outraged.
You can do this:
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_order(user_id: UserId, order_id: OrderId) -> Order:
...Now the type checker may notice that UserId and OrderID are different semantic types, although these remain the usual values in runtime.
This is especially useful for ID, email, token, money amount, external reference, and other values that have the same basic type but different meanings.
Generic types are needed, but not everywhere.
Generics are useful when a function or class actually preserves the relationship between input and output.
A simple example:
def first[T](items: list[T]) -> T:
return items[0]Here the type T is justified: if a list[User] has arrived, the User will return; if a list[Order], the Order will return.
But if generic doesn't help express such a relationship, it often just complicates the code.
Example:
T = TypeVar("T")
U = TypeVar("U")
V = TypeVar("V")and then no one in the team understands why this is necessary.
The rule is simple: use generic where it preserves the type relationship, not where you want it to be beautiful.
Overload is for an API that actually behaves differently.
Sometimes the type of result depends on the arguments.
For example:
from typing import overload
@overload
def read_config(path: str, as_json: Literal[True]) -> dict[str, object]:
...
@overload
def read_config(path: str, as_json: Literal[False]) -> str:
...
def read_config(path: str, as_json: bool) -> dict[str, object] | str:
content = Path(path).read_text()
if as_json:
return json.loads(content)
return contentoverload is useful when you describe an already existing API behavior.
But if you are designing new code, sometimes it is better not to complicate the types, but to divide the function into two:
def read_config_text(path: str) -> str:
...
def read_config_json(path: str) -> dict[str, object]:
...Typing should not save an inconvenient API. Sometimes it just shows that it's time to simplify the API.
Good typing doesn't start with 100% coverage.
The most painless way is to type not everything, but the most important boundaries.:
- public functions;
- complex return types;
- external payloads;
- domain IDs;
- interfaces between layers;
- places where there have already been bugs;
- code that is often refactored.
This provides more benefits than mechanically adding annotations to all local variables.
In a large project, it is better to implement typing gradually: first, new modules, then critical boundaries, then legacy code as changes occur.
Types are not a substitute for runtime validation.
It is very important.
If the data came from an external API, file, queue, or user input, type hints do not guarantee that the data is actually correct.
def handle(payload: EventPayload) -> None:
...This signature is useful inside the program. But if the payload came from JSON, it still needs to be checked in runtime.
Static typing answers the question:
How should this code be used?
Runtime validation answers another question.:
What really came from outside?
These are different tasks. They cannot be substituted for each other.
What is considered good typing?
Good type hints:
- explain the function contract;
- IDE and review help;
- catching real errors;
- Three auxiliary types of screens do not require reading;
- isolate Any;
- make refactoring safer;
- they don't try to describe the whole world perfectly.
Bad type hints:
- they duplicate the obvious;
- they mask a bad API;
- They turn a simple function into a mystery.;
- they force the team to argue about types more than about behavior.;
- dismiss Any throughout the project;
- they create a false sense of runtime security.
Practical checklist
Before adding a type, ask:
- Does it help you understand the contract?
- Does it help to catch the real mistake?
- Can't the type checker deduce this by itself?
- Is this type hiding a bad data structure?
- Isn't a runtime validator needed here instead of a type hint?
- Won't it become more difficult for the next developer?
If the type does not pass these questions, it may not be needed.
The main thing
Typing in Python should not be a formality, but a tool to reduce uncertainty.
There is no need to turn Python into a static language. You don't need to type every variable for the sake of a beautiful report. There is no need to be afraid of Anyone if they are isolated at the border. You don't need to write generic where a regular function reads better.
The most useful typing is the one that makes important boundaries explicit.:
- what the function accepts;
- what it returns;
- what is the shape of the external data?;
- which object fits the interface?;
- where the same basic types have different meanings.
When types help you think, they are useful. When types are forced to serve themselves, this is no longer engineering, but bureaucracy.
В Python типизация часто вызывает две крайние реакции.
Одни говорят: Python динамический, типы только портят читаемость. Другие превращают каждую функцию в такую конструкцию, что бизнес-логика теряется за TypeVar, Protocol, Generic, Callable, Literal, TypedDict и тремя слоями aliases.
Обе крайности не очень полезны.
Python-типизация хороша не тогда, когда код выглядит как в Java. И не тогда, когда типами покрыт каждый временный словарь. Она хороша тогда, когда помогает быстрее понимать код, безопаснее менять его и ловить ошибки до продакшена.
Главное — не относиться к типам как к религии. Это инструмент.
Главная мысль
Типизация в Python не должна быть украшением. Она должна отвечать на практический вопрос:
какую ошибку этот type hint помогает поймать или какую мысль о коде делает очевидной?
Если ответ непонятен, возможно, тип здесь не нужен. Если ответ очевиден — типизация начинает окупаться очень быстро.
Важно: Python не стал статическим языком
Первая вещь, которую нужно принять: type hints в Python сами по себе не заставляют runtime проверять значения.
def double(x: int) -> int:
return x * 2
print(double("ha"))Этот код не упадёт из-за аннотации x: int. Он выполнится и напечатает haha.
Типы в Python в первую очередь нужны инструментам:
- type checker;
- IDE;
- linter;
- refactoring tools;
- autocomplete;
- code review;
- документация для людей.
И это нормально. Python остаётся динамическим языком, но получает слой статического анализа там, где он помогает.
Где типизация даёт максимум пользы
Типы особенно полезны на границах.
1. Граница функции
Если функция принимает и возвращает неочевидные структуры, аннотация почти всегда полезна.
Плохо:
def build_report(data, options):
...Лучше:
def build_report(
data: list[Order],
options: ReportOptions,
) -> Report:
...Теперь видно не только что принимает функция, но и какой уровень абстракции ожидается.
2. Граница модуля
Внутри маленькой функции и так может быть всё понятно. Но публичный API модуля должен быть читаемым.
def send_invoice(invoice_id: InvoiceId, recipient: Email) -> SendResult:
...Такая сигнатура уже сама объясняет намерение.
3. Граница с внешними данными
JSON, API responses, конфиги, payload из очереди — всё это зоны повышенного риска.
Плохой вариант:
def handle_event(event: dict) -> None:
user_id = event["user"]["id"]
...Лучше хотя бы так:
from typing import TypedDict
class UserPayload(TypedDict):
id: int
email: str
class EventPayload(TypedDict):
type: str
user: UserPayload
def handle_event(event: EventPayload) -> None:
user_id = event["user"]["id"]
...TypedDict не превращает словарь в dataclass и не валидирует данные сам по себе. Но он документирует ожидаемую форму словаря и помогает статическому анализатору поймать ошибки в ключах и типах значений.
Не типизируйте очевидное ради галочки
Есть код, где аннотация почти ничего не добавляет:
count: int = 0
name: str = "Alice"
active: bool = TrueИногда это оправдано, например если переменная потом получает значение из разных веток. Но часто это просто шум.
Типизация должна снижать когнитивную нагрузку, а не повышать её.
Сравните:
def normalize_email(value: str) -> str:
return value.strip().lower()Здесь типы полезны: функция публичная, и её контракт очевиден.
А вот так уже спорно:
def normalize_email(value: str) -> str:
cleaned: str = value.strip()
lowered: str = cleaned.lower()
return loweredВнутренние переменные не всегда нужно аннотировать. Дайте type checker’у делать вывод типов там, где он и так справляется.
Any — это не зло, а карантинная зона
Многие боятся Any, потому что он ломает типизацию. И это правда: Any фактически говорит type checker’у доверься мне.
Но в реальном Python-коде Any иногда неизбежен:
- старые библиотеки без типов;
- динамический JSON;
- интеграции с внешними API;
- миграция большого проекта;
- monkey patching;
- плагины;
- ORM и metaprogramming.
Проблема не в самом Any. Проблема в том, что он расползается.
Хорошая практика — держать Any на границе и как можно быстрее превращать его в нормальную структуру.
Плохо:
def process(payload: Any) -> None:
user_id = payload["user"]["id"]
send_email(payload["user"]["email"])Лучше:
def parse_event(payload: Any) -> EventPayload:
# здесь может быть runtime-валидация
return payload
def process(event: EventPayload) -> None:
user_id = event["user"]["id"]
send_email(event["user"]["email"])Идея простая: внешний мир может быть хаотичным. Но внутри приложения хаос не должен бесконечно распространяться.
TypedDict — для словарей, которые вы не можете заменить моделью
TypedDict особенно полезен там, где словарь действительно остаётся словарём:
- JSON payload;
- response от внешнего API;
- конфиг;
- событие из очереди;
- legacy-код;
- лёгкий DTO без runtime-модели.
Но если структура живёт долго внутри вашего домена, часто лучше использовать dataclass, Pydantic-модель или обычный класс.
Например, для входящего JSON:
class GithubUserPayload(TypedDict):
id: int
login: str
avatar_url: strА для доменной сущности:
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
id: int
login: str
avatar_url: strТак код честно разделяет два мира:
- внешний словарь;
- внутренняя модель.
Это лучше, чем тащить dict[str, Any] через половину приложения.
Protocol — типизация без лишнего наследования
Один из самых питоничных инструментов типизации — Protocol.
Он позволяет описать не "кто ты", а "что ты умеешь".
Например, функции всё равно, какой конкретно объект ей передали. Ей важно, чтобы у объекта был метод send.
from typing import Protocol
class Sender(Protocol):
def send(self, message: str) -> None:
...
def notify(sender: Sender, text: str) -> None:
sender.send(text)Теперь любой объект с методом send(message: str) -> None подходит под этот контракт. Ему не нужно явно наследоваться от Sender.
Это хорошо совпадает с духом Python: duck typing остаётся, но получает статическую проверку.
Такой подход особенно полезен для:
- тестов;
- адаптеров;
- dependency injection;
- портов и интерфейсов;
- кода, который не должен зависеть от конкретной реализации.
NewType помогает не путать одинаковые базовые типы
Очень частая ошибка: всё является int или str, но смысл разный.
def get_order(user_id: int, order_id: int) -> Order:
...Технически легко перепутать аргументы местами:
get_order(order_id, user_id)Оба значения — int, runtime не возмутится.
Можно сделать так:
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_order(user_id: UserId, order_id: OrderId) -> Order:
...Теперь type checker может заметить, что UserId и OrderId — разные смысловые типы, хотя в runtime это остаются обычные значения.
Это особенно полезно для ID, email, token, money amount, external reference и других значений, которые имеют одинаковый базовый тип, но разный смысл.
Generic-типы нужны, но не везде
Дженерики полезны, когда функция или класс действительно сохраняют связь между входом и выходом.
Простой пример:
def first[T](items: list[T]) -> T:
return items[0]Здесь тип T оправдан: если пришёл list[User], вернётся User; если list[Order], вернётся Order.
Но если generic не помогает выразить такую связь, он часто просто усложняет код.
Пример:
T = TypeVar("T")
U = TypeVar("U")
V = TypeVar("V")и дальше никто в команде уже не понимает, зачем это нужно.
Правило простое: используйте generic там, где он сохраняет отношение типов, а не там, где хочется — сделать красиво.
Overload — для API, который реально ведёт себя по-разному
Иногда тип результата зависит от аргументов.
Например:
from typing import overload
@overload
def read_config(path: str, as_json: Literal[True]) -> dict[str, object]:
...
@overload
def read_config(path: str, as_json: Literal[False]) -> str:
...
def read_config(path: str, as_json: bool) -> dict[str, object] | str:
content = Path(path).read_text()
if as_json:
return json.loads(content)
return contentoverload полезен, когда вы описываете уже существующее поведение API.
Но если вы проектируете новый код, иногда лучше не усложнять типы, а разделить функцию на две:
def read_config_text(path: str) -> str:
...
def read_config_json(path: str) -> dict[str, object]:
...Типизация не должна спасать неудобный API. Иногда она просто показывает, что API пора упростить.
Хорошая типизация начинается не со 100% покрытия
Самый безболезненный путь — типизировать не всё подряд, а самые важные границы:
- публичные функции;
- сложные return types;
- внешние payload;
- доменные ID;
- интерфейсы между слоями;
- места, где уже были баги;
- код, который часто рефакторят.
Это даёт больше пользы, чем механическое добавление аннотаций ко всем локальным переменным.
В большом проекте типизацию лучше внедрять постепенно: сначала новые модули, потом критичные границы, потом legacy-код по мере изменений.
Типы не заменяют runtime-валидацию
Это очень важно.
Если данные пришли из внешнего API, файла, очереди или пользовательского ввода, type hints не гарантируют, что данные действительно правильные.
def handle(payload: EventPayload) -> None:
...Эта сигнатура полезна внутри программы. Но если payload пришёл из JSON, его всё равно нужно проверить в runtime.
Статическая типизация отвечает на вопрос:
Как этот код должен использоваться?
Runtime-валидация отвечает на другой вопрос:
Что реально пришло снаружи?
Это разные задачи. Их нельзя подменять друг другом.
Что считать хорошей типизацией
Хорошие type hints:
- объясняют контракт функции;
- помогают IDE и review;
- ловят реальные ошибки;
- не требуют читать три экрана вспомогательных типов;
- изолируют Any;
- делают refactoring безопаснее;
- не пытаются описать весь мир идеально.
Плохие type hints:
- дублируют очевидное;
- маскируют плохой API;
- превращают простую функцию в загадку;
- заставляют команду спорить о типах больше, чем о поведении;
- распускают Any по всему проекту;
- создают ложное ощущение runtime-безопасности.
Практический чек-лист
Перед тем как добавить тип, спросите:
- Помогает ли он понять контракт?
- Помогает ли он поймать реальную ошибку?
- Не может ли type checker вывести это сам?
- Не скрывает ли этот тип плохую структуру данных?
- Не нужен ли здесь runtime-validator вместо type hint?
- Не станет ли это сложнее для следующего разработчика?
Если тип не проходит эти вопросы, возможно, он не нужен.
Главное
Типизация в Python должна быть не формальностью, а инструментом снижения неопределённости.
Не нужно превращать Python в статический язык. Не нужно типизировать каждую переменную ради красивого отчёта. Не нужно бояться Any, если он изолирован на границе. Не нужно писать generic там, где обычная функция читается лучше.
Самая полезная типизация — та, которая делает важные границы явными:
- что функция принимает;
- что она возвращает;
- какая форма у внешних данных;
- какой объект подходит под интерфейс;
- где одинаковые базовые типы имеют разный смысл.
Когда типы помогают думать — они полезны. Когда типы заставляют обслуживать самих себя — это уже не инженерия, а бюрократия.