2025, Oct 05 03:00
Exhaustive Pattern Matching in mypy with TypeVar-Bound BaseModel: Why It Fails and Practical Fixes
Learn why mypy treats TypeVar-bound BaseModel matching as non-exhaustive and how to fix it: use concrete unions, assert_never, or workarounds for generic APIs.
When a generic class is parameterized by a TypeVar bounded to an abstract base derived from BaseModel, mypy will not treat pattern matching over the bounded base as exhaustive. The result is a false expectation that the wildcard branch is unreachable, while mypy correctly keeps the TypeVar as-is and rejects passing it to a function that expects Never.
Reproducing the problem
The following minimal example triggers the error because the scrutinee is a single TypeVar, not a concrete union of known subclasses. Removing BaseModel does not change the outcome.
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 reports: “Argument 1 to ‘fail_exhaustive’ has incompatible type ‘TCtx’; expected ‘Never’”.
Why this happens
This is a known limitation of mypy combined with Python’s structural pattern matching. Narrowing in match/case works when the scrutinee is a concrete union. In the example, self.ctx has the type TCtx, which is a single TypeVar bounded by CtxBase, not DashCtx | DefaultCtx. mypy has no proof that all subclasses are enumerated, so the wildcard branch remains reachable. In other words, nothing prevents a different subclass like a hypothetical OtherContext from existing elsewhere and being passed in. Because checking library code cannot depend on its unknown consumers, mypy treats the _ case as still being TCtx and rejects sending it to a function that requires Never. Sealed classes would make such reasoning possible, but they do not exist in Python typing today.
Practical ways to fix or work around
The solution depends on whether you need real exhaustiveness or you want to keep the generic API.
If you want exhaustiveness checking with match/case, make the scrutinee a finite union of concrete types. Introducing a type alias for that union lets mypy narrow each case and conclude that the wildcard is unreachable.
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)
If you must keep the generic shape, accept that mypy cannot prove exhaustiveness over an open hierarchy. In that case, either avoid the wildcard branch entirely or suppress the typing complaint locally.
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]
On Python 3.11+, typing.assert_never exists for this exact purpose, but it remains effective only when the input is a union of known types rather than a 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)
Why this matters
Pattern matching often reads cleaner than cascades of isinstance checks, but static exhaustiveness only works over closed sets. Treating an inheritance tree as open is fundamental to library design: new subclasses can appear anywhere. Relying on a TypeVar bound to a base class means the type checker cannot assume you have covered all cases, and the wildcard is legitimately reachable. Understanding this boundary helps you choose between expressiveness (open hierarchies) and stronger guarantees (closed unions).
Takeaways
If you need mypy to certify that match/case is exhaustive, model the input as a concrete union like DashCtx | DefaultCtx and use assert_never or a Never-accepting helper in the final branch. If you need a generic API over a base class, do not expect exhaustiveness checks; either remove the wildcard branch or silence the specific complaint where appropriate. Sealed classes would bridge this gap, but they are not available in Python’s typing system today.
The article is based on a question from StackOverflow by Nitsugua and an answer by Parsa Parvizi.