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 по проверке принадлежности — так вы избежите сюрпризов.

Статья основана на вопросе на StackOverflow от scnlf и ответе от InSync.