2025, Oct 31 07:17
Почему mypy не сужает тип до Literal при проверке in
Разбираем, почему в mypy проверка принадлежности in не сужает строку до Literal, чем это чревато и как обойти ограничение. Сравниваем с поведением pyright.
Статические анализаторы делают код безопаснее, но вместе с тем требуют строгого соблюдения правил. Часто удивляет, что проверка принадлежности — например, «строка входит в небольшой список допустимых значений» — не убеждает mypy, что значение имеет конкретный Literal. В итоге получаем ошибку, хотя во время выполнения соответствующая ветка выглядит безупречно.
Минимальный пример
from typing import Literal
def accept_token(tok: Literal["foo", "bar"]) -> None:
print(f"{tok=}")
def guarded_call(raw: str) -> None:
if raw in ["foo", "bar"]:
accept_token(raw)
else:
print("format incorrect")
Несмотря на проверку принадлежности, mypy сообщает, что аргумент имеет тип str и не сужается до Literal["foo", "bar"].
Что происходит
Главная причина в том, что mypy не рассматривает выражение value in ["literal1", "literal2"] как сужающее тип. Внутри защищённой ветки переменная по‑прежнему имеет тип str. Это легко увидеть по диагностике:
def guarded_call(raw: str) -> None:
if raw in ["foo", "bar"]:
reveal_type(raw) # `str`, а не `Literal["foo", "bar"]`
Существует открытый запрос на поддержку такого сужения, но сейчас mypy его не выполняет. Причина не только в отсутствии реализации: подобное сужение теоретически небезупречно. Literal вроде Literal['foo'] соответствует ровно одному значению — конкретной строке 'foo' с типом str. Проверка принадлежности по контейнерам строк может сработать и для значений, которые ведут себя как 'foo', но не являются тем самым значением типа str, например для экземпляров подклассов, переопределяющих сравнение и хеширование.
class QuasiFoo(str):
def __eq__(self, other):
return type(other) is str and other == "foo"
def __hash__(self):
return hash("foo")
print(QuasiFoo() in ["foo", "bar"]) # True
print(QuasiFoo() in ("foo", "bar")) # True
print(QuasiFoo() in {"foo", "bar"}) # True
Поскольку проверка вроде raw in ["foo", "bar"] пропускает и такие значения, сужать до Literal["foo", "bar"] в общем случае было бы некорректно.
Решение
С учётом текущего поведения mypy ветка не сужает raw до Literal, поэтому передача его в функцию, ожидающую Literal["foo", "bar"], приводит к ошибке типов. Внутри защищённого блока тип остаётся str, что и видно в диагностике. Есть открытая задача на поддержку такого сужения, но на данный момент оно недоступно.
Ещё одна деталь, часто всплывающая в командах с несколькими проверяющими: pyright умеет сужать тип по похожему шаблону при использовании кортежа литералов, например if raw in ("foo", "bar"):. Это не влияет на поведение mypy; разница лишь подчёркивает, что правила сужения типов у инструментов различаются.
Почему это важно
Типы Literal, проверка полноты вариантов и сужение типов важны, когда вы описываете конечные протоколы, флаги или конфигурационные значения. Предположение, что проверка принадлежности гарантирует Literal, может привести к расхождению между вашей моделью и тем, что реально контролирует проверяющий. Понимание «несостоятельности» случая с наследниками строк объясняет, почему mypy осторожен. Это также подчёркивает, как важно учитывать различия между статическими анализаторами: шаблон, который принимает один инструмент, другой может не распознать.
Выводы
Не рассчитывайте, что принадлежность списку, кортежу или множеству приведёт к сужению до Literal в mypy: внутри защищённого блока значение остаётся str. Если вашей функции действительно нужен Literal, стройте код вокруг поддерживаемых mypy способов сужения или следите за открытым запросом, чтобы видеть прогресс. Если в процессе вы используете несколько проверяющих, заранее уточняйте, как каждый из них обрабатывает сужение Literal по проверке принадлежности — так вы избежите сюрпризов.