2025, Oct 08 05:16

Инвариантность ключей Mapping в Python: почему mypy и pyright ругаются и как это исправить

Почему ключи Mapping в Python инвариантны, из‑за чего mypy и pyright отклоняют dict[str, str] для Mapping[str|int, str], и какие аннотации использовать вместо.

Передача обычного dict в функцию, которая ожидает Mapping с более широким типом ключа, на вид безобидна, но проверяющие типы не согласны. Ниже — почему mypy 1.16.0 и pyright 1.1.400 считают такой паттерн ошибочным и что с этим сделать, не споря с инструментами.

Как воспроизвести проблему

Функция ожидает Mapping, где ключи могут быть str или int, а в месте вызова передаётся dict только с ключами str. Проверяющие отвергают такой аргумент.

from collections.abc import Mapping

def consume_map(src: Mapping[str | int, str]):
    print(src)

payload = {"a": "b"}
consume_map(payload)

И mypy, и pyright сообщают, что dict[str, str] нельзя передать как Mapping[str | int, str]. Например, pyright поясняет, что параметр типа ключа у Mapping инвариантен, а str — это не то же самое, что str | int.

Почему так происходит

Несмотря на неизменяемость Mapping, параметр типа ключа в системе типов Python считается инвариантным. Предложения сделать ключи Mapping ковариантными выдвигались не раз и были отклонены. Поэтому проверяющие последовательно требуют: Mapping[K, V] не принимает Mapping[K_sub, V], если K_sub уже, чем K. В нашем случае dict[str, str] уже, чем Mapping[str | int, str], поэтому такое присваивание отклоняется.

Практическое решение

Простой способ угодить проверке — объявить словарь сразу с более широким типом ключа. Так намерение остаётся явным и соответствует сигнатуре функции.

from collections.abc import Mapping

def consume_map(src: Mapping[str | int, str]):
    print(src)

store: dict[str | int, str] = {"a": "b"}
consume_map(store)

Если ваша задача — принимать либо отображения с ключами только str, либо только int, можно выразить это прямо в типе параметра, использовав объединение вариантов Mapping: Mapping[str, str] | Mapping[int, str]. Это сработает как обходной путь, когда вам не нужен единый mapping, смешивающий оба вида ключей.

Почему это важно

Опора на интуицию, что неизменяемость означает безопасную ковариантность, приводит к неожиданным ошибкам на ревью или в CI. Действующие правила типизации однозначны: параметр типа ключа у Mapping инвариантен. И mypy, и pyright последовательно этому следуют. Зная это заранее, легче выбрать правильную стратегию аннотаций и обходиться без подавлений предупреждений и случайных приведений типов.

Выводы

Будьте конкретны с типами ключей при проектировании границ функций, принимающих отображения. Если потребителю действительно нужен Mapping[str | int, str], объявляйте источник данных с этим более широким типом ключа. Если же нужно обрабатывать либо словари со строковыми ключами, либо с целочисленными, укажите это как объединение Mapping[str, str] и Mapping[int, str]. И главное — не предполагайте, что неизменяемость меняет ситуацию с вариативностью: предложения сделать ключи Mapping ковариантными отклонены, поэтому инструменты применяют правило инвариантности.

Статья основана на вопросе на StackOverflow от Kerrick Staley и ответе от Anerdw.