2025, Oct 01 05:00

Why mypy doesn't narrow Any with issubclass(), and how to fix it with isinstance(type) guards or explicit type annotations

Learn why mypy won't narrow Any with issubclass(), the runtime risks involved, and how to fix it using isinstance(type) guards or explicit type annotations.

Why a simple issubclass() check can silently fail to narrow types in mypy, and what to do about it

Problem overview

Type narrowing with mypy often feels intuitive until Any enters the room. A frequent pitfall is expecting issubclass() to refine a value of type Any to a more precise type. In practice, that refinement doesn’t happen, and the code ends up with no useful narrowing in the branch that checks issubclass() directly.

Minimal example

The snippet below illustrates the mismatch between expectation and behavior. The first branch combines isinstance(x, type) with issubclass(), and mypy recognizes a proper narrowing. The second branch uses issubclass() alone and stays at Any.

from typing import Any

class BaseUnit:
    pass

def probe(x: Any) -> None:
    if isinstance(x, type) and issubclass(x, BaseUnit):
        reveal_type(x)  # Revealed type is "Type[BaseUnit]"

    if issubclass(x, BaseUnit):
        reveal_type(x)  # Revealed type is "Any"

What’s going on

There is a known issue in mypy where issubclass() doesn’t perform narrowing if the value under test is of type Any. Even though the check passes semantically, the type remains Any from the checker’s perspective, so the second branch in the example does not narrow the type.

There’s also a separate concern with the second condition: issubclass() expects its first argument to be a type. If it isn’t, the call may raise at runtime. In other words, calling issubclass(x, BaseUnit) acts as an implicit assertion that x is a type, which can be an undesirable side effect. If such an assertion is intended, it’s clearer and safer to reflect that in the type of the variable itself, for example by using x: type.

Implications, shown theoretically

The following illustrates the assertion side effect from a type-system point of view. This is a theoretical depiction; no major type checkers currently behave exactly like this, but it captures the idea that the call carries an implicit assumption about the first argument being a type.

# Theoretical output. No major type checkers currently work this way.
def inspect(y: Any):
    if issubclass(y, int):
        reveal_type(y)  # type[int]

    reveal_type(y)      # type       (!?)

How to approach the fix

There are two practical takeaways drawn directly from the behavior above. First, when working with Any, don’t expect issubclass() to narrow the type; this is a known limitation. Second, if the code calls issubclass(), make sure the first argument is actually a type. The safest path is to guard with isinstance(x, type) before issubclass(), or, if the assertion is intentional, annotate the variable as a type explicitly.

Corrected example

The first option makes the runtime requirement explicit and preserves narrowing in a way that mypy understands.

from typing import Any

class BaseUnit:
    pass

def verify(a: Any) -> None:
    if isinstance(a, type) and issubclass(a, BaseUnit):
        reveal_type(a)  # Revealed type is "Type[BaseUnit]"

If the code is meant to operate only on types, make that contract explicit up front. This avoids accidental misuse and clarifies intent.

class BaseUnit:
    pass

def ensure(b: type) -> None:
    if issubclass(b, BaseUnit):
        pass  # b is a type here by annotation

Why this matters

Relying on issubclass() for narrowing with Any can lull you into a false sense of safety. The check may pass at runtime, but the type information remains vague, which defeats static analysis and can hide errors. Moreover, passing a non-type into issubclass() can raise, so treating that call as a de facto assertion without making it explicit in the types risks fragile code.

Takeaways

If a value is Any, don’t expect issubclass() to narrow it; this is a known mypy limitation. When calling issubclass(), guarantee the first argument is a type either by adding an isinstance(x, type) guard or by annotating the variable as type when that’s the intended contract. These small adjustments keep both the type checker and the runtime behavior aligned, reducing surprises and making the codebase easier to reason about.

The article is based on a question from StackOverflow by Leonardus Chen and an answer by InSync.