Два int, разные сущности: как NewType ловит перепутанные ID

Функция получает идентификатор проекта и идентификатор пользователя. Оба представлены целыми числами. Перепутанные аргументы проходят обычную проверку типов, ведь для неё int ничем не отличается от int. Именованные параметры помогают читать вызов, но не останавливают передачу переменной project_id в user_id при перестройке кода.

Если доменные роли устойчивы, их можно выразить через NewType. Для проверяющего UserId и ProjectId становятся разными типами, связанными с int. Во время выполнения вызов UserId(7) возвращает исходное значение; нового класса для каждого числа не создаётся. Поэтому этот инструмент удобно вводить постепенно в существующий код, который уже хранит идентификаторы как числа.

Роль идентификатора в типе

Начинать лучше с функций на границе предметной логики. Они должны получать уже разобранные значения, а не случайный JSON. Парсер проверяет формат и допустимый диапазон, затем явно обозначает роль идентификатора. Внутри приложения эта роль путешествует вместе с аннотацией и проверяется mypy.

from typing import NewType

UserId = NewType("UserId", int)
ProjectId = NewType("ProjectId", int)

def grant(user_id: UserId, project_id: ProjectId) -> tuple[UserId, ProjectId]:
    return user_id, project_id

user = UserId(4)
project = ProjectId(9)
result = grant(user, project)
assert result == (4, 9)
print(*result)

Граница явного преобразования

После запуска будет выведено 4 9. Установите mypy командой python -m pip install mypy и выполните python -m mypy --strict ids.py. Затем поменяйте местами переменные в grant(project, user). Проверяющий должен сообщить о несовместимых типах обоих аргументов. Сам Python такую перестановку не остановит, поэтому проверка типов должна выполняться вместе с остальными проверками проекта.

Не создавайте NewType в каждой функции заново: определения нужно импортировать из одного небольшого модуля доменных типов. Иначе одинаковые по смыслу названия могут представлять разные статические типы. Такой модуль не должен импортировать хранилище или сервисы. Ему достаточно стандартной библиотеки, а зависимости остальных частей направлены к нему.

Обратите внимание на место явного преобразования. Если написать UserId(project) прямо перед grant, статическая ошибка исчезнет, но предметный смысл останется неправильным. Конструктор NewType обозначает утверждение автора, а не проверяет его. Поэтому полезно договориться, что такие преобразования сосредоточены в адаптерах входных данных и чтения хранилища. Бизнес-операции уже используют готовые значения.

Для денежных величин, единиц измерения и составных ключей может потребоваться полноценный класс со своей арифметикой и инвариантами. NewType хорошо решает более узкую задачу: различить одинаковое представление данных по назначению. Проверьте один негативный пример с перестановкой аргументов и один обычный сценарий. Если mypy перестаёт видеть различие после промежуточной функции, найдите её слишком широкую аннотацию раньше, чем добавлять cast.

Следующий шаг — перенести этот приём из отдельного файла в поддерживаемое приложение. В курсе «Python для продвинутых: ООП, типизация и тестирование» структура пакетов, объектная модель, mypy и pytest соединяются в типизированном трекере задач с хранением данных и логированием. Можно отработать связь между интерфейсом, реализацией и регрессионным тестом, а затем применить её к своему проекту.

Посмотреть программу и практику курса.

Документация: официальный источник по теме.

Другие задачи по теме: Типы, тесты и поведение программы.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.