Structural Pattern Matching for Types

I’m sure that there are other use cases for this, but the main one that comes to mind is inferring the type of function parameters. This has been discussed at Extract kwargs types from a function signature (however I am proposing something more general). It is common that you want to override a superclass method, for example, which may accept many parameters that you don’t need in the override:

class MyClass(BaseClass):
    def my_method(self, *args, **kwargs):
        ...
        return super().my_method(*args, **kwargs)

Unfortunately, most libraries, even if they provide type stubs, do not provide a TypedDict subclass that you can simply import in order to use Unpack[MyMethodKwargs], so the only way to not lose typing information is to redeclare all of the parameters.

If we could infer a ParamSpec type from the method type, and if we could infer the method type from the method object, then we could do something like:

class MyClass(BaseClass):
    def my_method(
        self,
        *args: Params[type[BaseClass.my_method]].args,
        **kwargs: Params[type[BaseClass.my_method]].kwargs,
    ):
        ...
        return super().my_method(*args, **kwargs)

I think that Python badly needs this, as **kwargs is so ubiquitous and one of the most notable places where type information is lost in my experience.

The way that TypeScript implements this is by leveraging pattern matching within conditional types:

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

This would be a very cool feature as it could be used for many other patterns, enabling things like Params[T], Return[T], KeyOf[T], ValueOf[T], etc.

Unfortunately, since expressions were not implemented for structural pattern matching, we can’t directly mirror the syntax, but it could maybe look something like:

type Params[C: Callable[..., Any]] = match C: P if case Callable[P, Any] else Never

or:

type Params[C: Callable[..., Any]] match C:
    case Callable[P, Any]: P
    case _: Never
7 Likes

FWIW, I suggested something similar in Make `@overload` less verbose - #10 by jorenham, with a syntax like

Note sure if it’s better, but I thought I’d just throw it out there.

Unless I’m missing something, I think that this these “case type”'s or “type mappings” (whatever you want to call it), generalize overloads. Put differently, I think that all overloads could be rewritten in terms using these type-mappings. It’s even more DRY that way, because it allows you to recycle (parts of) you overload signatures, because it decouples the overloaded signature from the function definition. It also provides a natural way for composing these definitions, which allows you can re-use some of the overloads.

Yes, it is similar, but in order to enable the kind of use-case I’m thinking of (specifically, inferring an arbitrary ParamSpec from a Callable type), it needs to have the ability to “extract” parts of the case constraint, similar to what is done in runtime structural pattern matching. This could theoretically be done even without cases:

type Params[C: Callable[P, Any]] = P

However, including the concept of cases makes this even more powerful by covering what you’re talking about too, and it’s more similar to how pattern matching has already been implemented.

Just to flesh this out a bit more, we could reuse the rules of PEP 636 for distinguishing “capture” variables vs. “value” variables:

type Params[C: Callable[..., Any]] match C:
    case Callable[P, typing.Any]: P
    case _: Never

Alternatively, we could rely on quoting:

type Params[C: Callable[..., Any]] match C:
    case Callable[P, "Any"]: P
    case _: Never

or introduce new syntax:

type Params[C: Callable[..., Any]] match C:
    case Callable[infer P, Any]: P
    case _: Never

We could also support nesting via either syntax suggested in my first post (just as examples):

type Params[C: Callable[..., Any]] = (
    match C: (
        match P: Y if case X else Z
        if case Callable[P, Any]
        else Never
    )
)

type Params[C: Callable[..., Any]] match C:
    case Callable[P, Any] match P:
        case X: Y
        case _: Z
    case _: Never

I personally find the second syntax more readable, and it is more similar to PEP 636.

1 Like

It is also worth noting that, while case types do cover a similar use case as function overloads, case types are more powerful due to their ability to express recursion. For example:

type Flatten[T: tuple[Any, ...]] match T:
    case tuple[A, *R]: tuple[
        *Flatten[match A: A if case tuple[Any, ...] else tuple[A]],
        *Flatten[tuple[*R]],
    ]
    case _: tuple[()]

type MyFlattened = Flatten[tuple[A, B, C, tuple[D, E, tuple[F, G]]]]  # tuple[A, B, C, D, E, F, G]

This enables type transformations like Reversed, Zip, DeepMap, etc. Given inlined TypedDict definitions, we could also flatten or deeply transform dictionary types as well.

2 Likes

It might even be Turing complete then :thinking: