Use a typeddict as the `case` guard to match structure

I would like to use typeddicts as the case guards in a match statement:

from typing import TypedDict

class A(TypedDict):
    x: int


class B(TypedDict):
    y: int


def func(value):
    match value:
        case A:
            print("it's an A")
        case B:
            print("it's a B")
        case _:
            print("it's neither")


func({"y": 1})   # should print 'it's a B"

# instead I get this error:
# SyntaxError: name capture 'A' makes remaining patterns unreachable

Why is this not possible? Should it not be a natural extension of structural pattern matching?

1 Like

A couple of reasons. This on the surface makes sense, but is actually trying to use two complicated special cases together, like a zip tie and a rivet, instead of using ordinary nuts and bolts. Firstly TypedDict doesn’t even support isinstance checks at run time:

from typing import TypedDict

class A(TypedDict):
    x: int

isinstance({'x' : 1}, A)
    raise TypeError('TypedDict does not support instance and class checks')
TypeError: TypedDict does not support instance and class checks

Secondly the reason you’re getting a Syntax error (and why I personally avoid using match statements in Python), is the patterns after the case statement in the Python language spec, are not any arbitrary Python expression. They must be specific “patterns”. In particular, they must fall under one of the specific categories of pattern in the docs:

1 Like

I think the better way to expect it to work would be case A(): rather than case A:
since case A: looks like a capture pattern than should bind something to the name A

But yes, it seems like this should work - especially since typing.get_type_hints(A) basically gives what’s needed for the mapping pattern.

A in the case clause is not a name bound to a particular type; it’s just a capture pattern used to assign the value of value to the name A. If you want to match against a type, you need to use a class pattern.

match value:
    case A():
        ...

This still produces an error, but it gets to the heart of your request:

TypeError: TypedDict does not support instance and class checks

TypedDicts are essential static-only constructs; they do not support runtime checks for conformance to the protocol they define.

(Which leads to a related question: is there a reason that runtime_checkable is only for Protocol, and not TypedDict?)

(And apologies, I’m responding more as if this were a help request than as a proposal to add the support.)

1 Like

match-case is not a simple switch statement, but rather an implementation of pattern matching.
As such, it’s necessary to characterize what the destructuring pattern match does.

Although we can do this for literal values, it’s not doable for types. Types can be complex values (e.g. a union of protocols) which are not guaranteed to be runtime checkable.
There have been several requests for more runtime support for type annotations, and this might count as another. But we have to make sure to begin from the common starting point that types are not necessarily understood at runtime today, and that drives a lot of relevant behaviors.

Also, as has been noted above, case binds names, so you need to use an appropriate non-name expression for the pattern.

It seems like we could change this:

class_pattern A():
  do what we currently do with normal classes

to this:

class_pattern A():
  if A is a TypedDict:
    call typing.get_type_hints(A) and use the result for mapping_pattern
  else:
    do what we currently do with normal classes

This creates a different TypedDict with the same metaclass that passes run-time isinstance checks, and even seems to work with match (after fixing the Syntax error from the original example’s match statement, to create Class matches):

from collections.abc import Mapping
from typing import TypedDict, _TypedDictMeta


def _user_attr_vals(inst):
    built_in_attrs = set(dir(dict))
    return {attr : getattr(inst, attr)
            for attr in dir(inst) 
            if attr not in built_in_attrs
           }


class _RuntimeCheckableTypedDictMeta(type):
    """ Takes a wrecking ball to:
        https://github.com/python/cpython/blob/af00c586525f6018c7ecd877cddeb936f9b2aedf/Lib/typing.py#L3136
    """
    __new__ = TypedDict.__new__
    __call__ = dict


    def __instancecheck__(cls, inst):
        if not isinstance(inst, Mapping):
            # f"{inst} is not a mapping"
            return False
            
        annotations = cls.__annotations__
        
        if not set(inst) == set(annotations):
            #(f"{inst} has missing or extra keys not in: {cls}"
            # f", keys: {set(inst)}, {set(annotations)=}")
            return False
        
        for k, v in inst.items():
            if not isinstance(v, annotations[k]):
                # f'Instance var: {v} is not a {annotations[k]=}')
                return False
               
        return True
        
class RuntimeCheckableTypedDict(metaclass=_RuntimeCheckableTypedDictMeta):
    pass
        
class A(RuntimeCheckableTypedDict):
    x: int

class B(RuntimeCheckableTypedDict):
    y: int
    
    
assert isinstance({'x' : 1}, A)
assert not isinstance({'x': 1, 'y' : 2}, A), "Failed extra key check"
assert not isinstance({'y' : 2}, A), "Failed missing key check"



def try_match_A_or_B(value):
    match value:
        case A():
            return A
        case B():
            return B
        case _:
            return None


assert try_match_A_or_B({"x": 1}) is A
assert try_match_A_or_B({"y": 1}) is B
assert try_match_A_or_B({"x": 1, "z" : 1}) is None  
assert try_match_A_or_B({"z": 1, "y" : 1}) is None  
assert try_match_A_or_B({"z": 1}) is None  

print('All checks passed.')

This does not support TypedDict’s functional syntax, total, Required or NotRequired. I normally avoid metaclass Black Magic too so could’ve made some howling errors. I do not claim this will work with the type spec, let alone specific type checkers.

I think the way that runtime_checkable works for Protocol would be less valuable than we might like here, because it doesn’t check types (though it would be more valuable than the current situation).

@runtime_checkable
class A(Protocol):
    x: int

isinstance(a, A)
# This only checks whether the attribute `x` exists, not the type of `x`

Thank you everyone for the replies and insightful answers.

I understand that the matching fails because you cannot make instances of typeddicts, fair enough. However, it still surprises me that a typeddict does not behave like a dict (i.e. a mapping) or a mapping pattern. By writting a typeddict, I’m encoding an expected structure, and it would be awesome to be able to use it in a case; otherwise, I end up having to use a dataclass.