2025, Oct 05 03:15

Почему mypy не доказывает исчерпываемость match/case для TypeVar и как это исправить

Почему mypy с TypeVar, ограниченным BaseModel, не считает match/case исчерпывающим и как обойти: закрытое union, assert_never или локальное подавление.

Когда обобщённый класс параметризован TypeVar, ограниченным абстрактной базой, унаследованной от BaseModel, mypy не считает сопоставление с образцом по этой базе исчерпывающим. В итоге возникает ложное ожидание, что ветка с подстановочным шаблоном недостижима, тогда как mypy корректно сохраняет TypeVar как есть и отклоняет передачу его в функцию, ожидающую Never.

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

Ниже приведён минимальный пример, который вызывает ошибку, потому что сопоставляемое значение — это одиночный TypeVar, а не конкретное объединение известных подклассов. Удаление BaseModel не меняет результат.

from typing import Generic, NoReturn, TypeVar
from pydantic import BaseModel
from abc import ABC


def fail_exhaustive(x: NoReturn) -> NoReturn:
    raise AssertionError("Exhaustiveness check failed: unhandled case")


class CtxBase(BaseModel, ABC):
    pass


class DashCtx(CtxBase):
    pass


class DefaultCtx(CtxBase):
    pass


TCtx = TypeVar("TCtx", bound=CtxBase)


class ScopedAction(Generic[TCtx]):
    def __init__(self, ctx: TCtx) -> None:
        self.ctx = ctx

    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return f"Applying action in {type(self.ctx)}"
            case DefaultCtx():
                return f"Applying action in {type(self.ctx)}"
            case _:
                fail_exhaustive(self.ctx)

mypy сообщает: “Argument 1 to ‘fail_exhaustive’ has incompatible type ‘TCtx’; expected ‘Never’”.

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

Это известное ограничение mypy в связке со структурным сопоставлением с образцом в Python. Сужение типа в match/case работает, когда сопоставляемое значение — конкретное объединение. В примере self.ctx имеет тип TCtx — это одиночный TypeVar, ограниченный CtxBase, а не DashCtx | DefaultCtx. У mypy нет доказательства, что перечислены все подклассы, поэтому ветка с подстановочным шаблоном остаётся достижимой. Иными словами, ничто не мешает существовать где-то другому подклассу, например гипотетическому OtherContext, и быть переданным внутрь. Поскольку проверка библиотечного кода не может зависеть от неизвестных потребителей, mypy считает, что в ветке _ по-прежнему лежит TCtx, и отклоняет передачу в функцию, требующую Never. Запечатанные (sealed) классы позволили бы такую логику, но в системе типов Python их пока нет.

Практические способы исправить или обойти

Подход зависит от того, нужна ли вам реальная исчерпываемость или важно сохранить обобщённый API.

Если нужна проверка исчерпываемости через match/case, сделайте сопоставляемое значение конечным объединением конкретных типов. Введя псевдоним типа для такого объединения, вы позволите mypy сузить тип в каждой ветке и вывести, что подстановочный шаблон недостижим.

from typing import Union

AllowedCtx = DashCtx | DefaultCtx


class ScopedAction:
    def __init__(self, ctx: AllowedCtx) -> None:
        self.ctx = ctx

    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return f"Applying action in {type(self.ctx)}"
            case DefaultCtx():
                return f"Applying action in {type(self.ctx)}"
            case _:
                fail_exhaustive(self.ctx)

Если нужно сохранить обобщённую форму, примите, что mypy не сможет доказать исчерпываемость над открытой иерархией. Тогда либо вовсе уберите подстановочную ветку, либо локально подавите предупреждение по типам.

class ScopedAction(Generic[TCtx]):
    def __init__(self, ctx: TCtx) -> None:
        self.ctx = ctx

    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return "Dashboard"
            case DefaultCtx():
                return "Default"
            case _:
                fail_exhaustive(self.ctx)  # type: ignore[arg-type]

В Python 3.11+ есть typing.assert_never ровно для такой цели, но он эффективен только тогда, когда на вход подаётся объединение известных типов, а не TypeVar.

from typing import assert_never

class ScopedAction:
    def __init__(self, ctx: AllowedCtx) -> None:
        self.ctx = ctx

    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return "Dashboard"
            case DefaultCtx():
                return "Default"
            case _:
                assert_never(self.ctx)

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

Сопоставление с образцом часто выглядит чище, чем каскады isinstance, но статическая исчерпываемость работает только над закрытыми наборами. Рассматривать дерево наследования как открытое — базовый принцип проектирования библиотек: новые подклассы могут появиться где угодно. Если вы опираетесь на TypeVar, ограниченный базовым классом, то проверяющий типы инструмент не может считать, что вы охватили все варианты, и подстановочная ветка действительно достижима. Понимание этой границы помогает выбирать между выразительностью (открытые иерархии) и более сильными гарантиями (закрытые объединения).

Выводы

Если вам нужно, чтобы mypy подтверждал исчерпываемость match/case, моделируйте вход как конкретное объединение, например DashCtx | DefaultCtx, и используйте assert_never или вспомогательную функцию, принимающую Never, в финальной ветке. Если вам нужен обобщённый API на базе базового класса, не рассчитывайте на проверку исчерпываемости; либо уберите подстановочную ветку, либо адресно приглушите конкретную жалобу типизатора. Запечатанные классы закрыли бы этот разрыв, но в текущей системе типов Python их нет.

Статья основана на вопросе на StackOverflow от Nitsugua и ответе Parsa Parvizi.