Structural Pattern Matching - Add support for `typing.NewType` and `typing.TypeAliasType`

TL;DR

Change typing.NewType and typing.TypeAliasType to be metaclasses or class constructors to allow pattern matching and isinstance checks.

The Problem

typing.NewType is here to remove type confusion between string types, string types, string types, and string types [1], and only a few of them have strictly-typed counterparts (like pathlib.Path), as well as other similar cases.

typing.TypeAliasType is a type of object created by a new type X = ... statement, which is extremely useful for declaring named unions [2] or generic shortcuts [3].

You try combining them because it’s looks nice and declarative, these NewTypes start looking like real classes: type UserID = NewType('UserID', str)… until you realise that you can no longer instantiate them.

And the obvious next step when using Python 3.12 is to combine new typing syntaxes with the structural pattern matching.

HTMLTag = NewType('HTMLTag', str)

def parse_tag(el: Element):
    match el.tag:
        case HTMLTag('div'):
            print("processing div...")
        case HTMLTag('span'):
            print("processing span...")
        case HTMLTag(tag):
            print(f"Skipping unsupported tag: {tag}")

But this doesn’t work…

TypeError: called match pattern must be a class

And I have a proposal suggestion to fix both problems.

The Proposal

Change NewType() and type ... to return a fake type instead of a typing proxy object.

Long code example here

new_typing.py

import typing
from abc import ABCMeta
from typing import overload


class NewType[T](ABCMeta):
    __supertype__: typing.Type[T]
    def __new__(cls, name: str, tp: typing.Type[T]):
        result = super().__new__(cls, name, (tp, ), { })
        result.__module__ = typing._caller()
        result.__supertype__ = tp
        result.__new__ = lambda _cls, x: x
        if (hasattr(tp, '__match_args__')):
            result.__match_args__ = tp.__match_args__
        result = typing.final(result)
        return result
    
    def __init__(cls, name: str, tp: typing.Type[T]):
        super().__init__(name, (tp, ), { })
    
    def __instancecheck__(cls, other):
        return isinstance(other, cls.__supertype__)
    def __subclasscheck__(cls, other):
        return issubclass(other, cls.__supertype__)

type _AnyType = typing.Type | typing.Union | typing._GenericAlias
@overload
def _extract_arg(name: str, value: _AnyType) -> tuple[str, _AnyType]:
    ...
@overload
def _extract_arg(alias: typing.TypeAliasType) -> tuple[str, _AnyType]:
    ...
def _extract_arg(*args):
    match (args):
        case (typing.TypeAliasType(__name__=name, __value__=value), ):
            return name, value
        case (name, value):
            return name, value
        case _:
            raise TypeError(f"Invalid arguments: {args}")

class TypeAliasType(ABCMeta):
    __value__: _AnyType
    
    @typing.overload
    def __new__(cls, name: str, value: _AnyType):
        ...
    @typing.overload
    def __new__(cls, alias: typing.TypeAliasType):
        ...
    def __new__(cls, *args):
        name, value = _extract_arg(*args)
        if (isinstance(value, NewType)):
            return value
        result = super().__new__(cls, name, (), { })
        result.__module__ = typing._caller()
        result.__value__ = value
        result.__new__ = lambda _cls, x: x
        if (hasattr(value, '__match_args__')):
            result.__match_args__ = value.__match_args__
        result = typing.final(result)
        return result
    
    @typing.overload
    def __init__(cls, _AnyType):
        ...
    @typing.overload
    def __init__(cls, alias: typing.TypeAliasType):
        ...
    def __init__(cls, *args):
        name, _ = _extract_arg(*args)
        super().__init__(name, (), {})
    
    def __instancecheck__(cls, other):
        return isinstance(other, cls.__value__)
    def __subclasscheck__(cls, other):
        return issubclass(other, cls.__value__)


__all__ = \
[
    'TypeAliasType',
    'NewType',
]

new_typing.pyi

from typing import TypeAliasType, NewType


__all__ = \
[
    'TypeAliasType',
    'NewType',
]

new_types.py

import typing
from dataclasses import dataclass

from new_type_patterns.new_typing import NewType, TypeAliasType

type HTMLTag = NewType('HTMLTag', str)
type SupportedArg = str | int | float | bool
type StrMapping[T] = typing.MutableMapping[str, T]

HTMLTag = TypeAliasType(HTMLTag)
SupportedArg = TypeAliasType(SupportedArg)
StrMapping = TypeAliasType(StrMapping)

@dataclass
class Element:
    tag: HTMLTag
    text: str


__all__ = \
[
    'HTMLTag',
    'StrMapping',
    'SupportedArg',
    
    'Element',
]

new_types.pyi

import typing
from dataclasses import dataclass

from typing import NewType

HTMLTag = NewType('HTMLTag', str)
type SupportedArg = str | int | float | bool
type StrMapping[T] = typing.MutableMapping[str, T]

@dataclass
class Element:
    tag: HTMLTag
    text: str

__all__ = \
[
    'HTMLTag',
    'StrMapping',
    'SupportedArg',
    
    'Element',
]

main.py

import sys

from new_type_patterns.new_types import Element, HTMLTag, SupportedArg
from new_type_patterns.new_typing import NewType


def parse_tag(el: Element):
    match (el.tag):
        case HTMLTag('div'):
            print("processing div...")
        case HTMLTag('span'):
            print("processing span...")
        case HTMLTag(tag):
            print(f"Skipping unsupported tag: {tag}")
        case _:
            raise TypeError(f"Unknown tag type: type={type(el.tag)}, value={el.tag}")

def parse_arg(arg: SupportedArg):
    match (arg):
        case str(s):
            print(f"Processing string: {s}")
        case SupportedArg():
            print(f"Processing supported value: {arg}")
        case _:
            raise TypeError(f"Unknown arg type: type={type(arg)}, value={arg}")


def check_newtype_cls():
    print(HTMLTag, repr(HTMLTag), str(HTMLTag), type(HTMLTag))
    print(isinstance(HTMLTag, NewType), issubclass(HTMLTag, NewType), issubclass(HTMLTag, str))
    print()

def check_newtype_pattern_matching():
    parse_tag(Element('div', "content"))
    parse_tag(Element('span', "span content"))
    parse_tag(Element('a', 'https://example.com'))
    try:
        parse_tag(Element(42, "answer to everything"))
    except TypeError as e:
        print("Error:", type(e), e)
    print()

def check_typealias_pattern_matching():
    parse_arg('some string')
    parse_arg(37)
    parse_arg(False)
    try:
        parse_arg((1, 2, 3))
    except TypeError as e:
        print("Error:", type(e), e)
    print()

def main():
    check_newtype_cls()
    check_newtype_pattern_matching()
    check_typealias_pattern_matching()
    
    return 0

if (__name__ == '__main__'):
    exit_code = main()
    exit(exit_code)

So, what have I done?
I just created new implementations for the NewType and TypeAliasType which are metaclasses supporting instance and subclass checks.

And it works:

<class 'new_type_patterns.new_types.HTMLTag'> <class 'new_type_patterns.new_types.HTMLTag'> <class 'new_type_patterns.new_types.HTMLTag'> <class 'new_type_patterns.new_typing.NewType'>
True False True

processing div...
processing span...
Skipping unsupported tag: a
Error: <class 'TypeError'> Unknown tag type: type=<class 'int'>, value=42

Processing string: some string
Processing supported value: 37
Processing supported value: False
Error: <class 'TypeError'> Unknown arg type: type=<class 'tuple'>, value=(1, 2, 3)

I would be glad to hear any feedback, positive or not. Preferably constructive.


  1. It’s obvious, they are: identifiers, paths, classnames, and human-readable texts ↩︎

  2. like type SupportedArg = str | int | float | bool ↩︎

  3. like type StrMapping[T] = MutableMapping[str, T] ↩︎

3 Likes