How can you match on a union of types?

I would like to exhaustively match against a union of type[T] | type[V]. For example:

from typing_extensions import assert_never

def f(t: type[int] | type[float]) -> None:
    match t:
        case int:
            print("it's a int")
        case float:
            print("it's a float")
        case _ as unreachable:
            assert_never(unreachable)

f(int)
f(float)

But this results in a SyntaxError:

    case int:
         ^^^
SyntaxError: name capture 'int' makes remaining patterns unreachable

This can be changed to:

from typing_extensions import assert_never

def f(t: type[int] | type[float]) -> None:
    match t:
        case _ if t is int:
            print("it's a int")
        case _ if t is float:
            print("it's a float")
        case _ as unreachable:
            assert_never(unreachable)

But this makes it no longer possible for type checkers to verify the exhausitveness of checking the union:

$ mypy main.py
main.py:10: error: Argument 1 to "assert_never" has incompatible type "Union[Type[int], Type[float]]"; expected "NoReturn"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Is there something I’m missing to make this work in a simple way?

1 Like

Huh, interesting, guess those cases aren’t well covered yet?

I managed to hack it to make it work like so:

from typing import assert_never


def f(t: type[int] | type[float]) -> None:
    if issubclass(t, int):
        print("it's a int")
    elif issubclass(t, float):
        print("it's a float")
    else:
        assert_never(t)


f(int)
f(float)

I guess you can use the same trick to match:

from typing import assert_never


def f(t: type[int] | type[float]) -> None:
    match t:
        case _ if issubclass(t, float):
            print("it's a float")
        case _ if issubclass(t, int):
            print("it's a int")
        case _ as unreachable:
            assert_never(unreachable)


f(int)
f(float)
1 Like

@brandtbucher @guido Curious, was this an intentionally forbidden use case of pattern matching or something that could be requested as a feature/bug request?

You could use the fully qualified names of the classes (by importing builtins):

import builtins
from typing_extensions import assert_never

def f(t: type[int | float]) -> None:
    match t:
        case builtins.int:
            print("it's a int")
        case builtins.float:
            print("it's a float")
        case _ as unreachable:
            assert_never(unreachable)

f(int)
f(float)

However, mypy still won’t infer _ in the final case statement as being unreachable. And mypy is correct here, because type[T] is covariant, but the above pattern-matching is working through equality comparisons. Consider the following:

import builtins
from typing_extensions import assert_never

def f(t: type[int | float]) -> None:
    match t:
        case builtins.int:
            print("it's a int")
        case builtins.float:
            print("it's a float")
        case _ as unreachable:
            assert_never(unreachable)

class IntSubclass(int): pass

# This is fine according to your type annotation,
# as `IntSubclass` is a subclass of `int`,
# but this call will fail with an `AssertionError`
f(IntSubclass)
2 Likes

Is this an issue with patten matching or just a missing feature/bug for this edge case in the type checker? As the PEP and docs explain, you can never compare a subject to a clause that’s a bare name, because a bare name means to capture in that variable. So you would have to use a qualified (dotted) name for those types, like builtins.int. However, mypy doesn’t seem to detect the unreachability using those names. You could open an issue for that in their repo?

EDIT: nevermind, Alex’s post almost at same time as mine mentioned the same thing about builtins module and explains why mypy behaves this way.

Unfortunately there’s no way in the type system currently to say “exactly the type T, and not a subclass of it”. That would be useful in this situation.

Ah, right. I had forgotten you need either . or () for matching to know whether you intended to bind to a new local or not. That was half the confusion, and the other half (covariance) you eloquently explained. Thanks @AlexWaygood!

1 Like

Not intentionally forbidden… it’s just that what you want here is a “value pattern” (since you’re actually matching by the subject by equality, not by its type). That requires a dotted name, as @AlexWaygood helpfully pointed out.

We also have class patterns, for when you’re trying to match instances of types (which is different from what you’re explicitly asking, but might be applicable to your use case nevertheless):

def f(o: int | float) -> None:
    match o:
        case int():
            print("it's an int")
        case float():
            print("it's a float")
        case unreachable:
            assert_never(unreachable)
2 Likes

How do you handle cases where the type is defined in the same module? For example:

class A:
    pass

class B:
    pass

def f(t: type[A] | type[B]):
    match t:
        case ????.A:
            pass
        case ????.B:
            pass

I can’t think of any way to use a dotted name here.

Maybe this is the more reasonable construction for this particular case?

if t is A:
    pass
elif t is B:
    pass

It just gets repetitive.