Type hinting a decorator that adds a base class

I might be doing some shenanigans here, but I’m trying to write a decorator that 1. adds a base class (mixin-style) and 2. makes it a dataclass.

In this example, I’m using Foo.__call__ as the decorator which adds MyBase.

from dataclasses import asdict, dataclass
from typing import dataclass_transform, TYPE_CHECKING

if TYPE_CHECKING:
    from _typeshed import DataclassInstance
else:
    DataclassInstance = object

class MyBase(DataclassInstance):
    @classmethod
    def lorem(cls, *args, **kwargs):
        return cls(*args, **kwargs)

    def ipsum(self):
        return asdict(self)

class Foo:
    @dataclass_transform()
    def __call__(self, cls, **kwargs): # -> ???
        def wrap(cls):
            if MyBase not in cls.__bases__:
                cls = type(cls.__name__, (MyBase,) + cls.__bases__, dict(cls.__dict__))
            return dataclass(cls, **kwargs)

        if cls is None:
            return wrap # @foo(...)
        return wrap(cls) # @foo

foo = Foo()

@foo
class Struct:
    bar: str
    baz: int

s = Struct(bar="yo", baz="something")
print(s)

s = Struct("yo", "something")
print(s)

s = Struct.lorem(bar="yo", baz="something")
print(s)

print(s.ipsum())

this runs fine:

Struct(bar='yo', baz='something')
Struct(bar='yo', baz='something')
Struct(bar='yo', baz='something')
{'bar': 'yo', 'baz': 'something'}

but type checkers can’t figure out that Struct has MyBase as a parent and is a dataclass:

$ mypy test.py 
test.py:38: error: Unexpected keyword argument "bar" for "Struct"  [call-arg]
/usr/lib/python3.14/site-packages/mypy/typeshed/stdlib/builtins.pyi:101: note: "Struct" defined here
test.py:38: error: Unexpected keyword argument "baz" for "Struct"  [call-arg]
/usr/lib/python3.14/site-packages/mypy/typeshed/stdlib/builtins.pyi:101: note: "Struct" defined here
test.py:42: error: Too many arguments for "Struct"  [call-arg]
test.py:46: error: "type[Struct]" has no attribute "lorem"  [attr-defined]
test.py:50: error: "Struct" has no attribute "ipsum"  [attr-defined]
Found 5 errors in 1 file (checked 1 source file)

$ ty check test.py
error[unknown-argument]: Argument `bar` does not match any known parameter of bound method `__init__`
  --> test.py:38:12
   |
36 | # No parameter named "bar"
37 | # No parameter named "baz"
38 | s = Struct(bar="yo", baz="something")
   |            ^^^^^^^^
39 | print(s)
   |
info: Method signature here
   --> stdlib/builtins.pyi:136:9
    |
134 |     @__class__.setter
135 |     def __class__(self, type: type[Self], /) -> None: ...
136 |     def __init__(self) -> None: ...
    |         ^^^^^^^^^^^^^^^^^^^^^^
137 |     def __new__(cls) -> Self: ...
138 |     # N.B. `object.__setattr__` and `object.__delattr__` are heavily special-cased by type checkers.
    |
info: rule `unknown-argument` is enabled by default

error[unknown-argument]: Argument `baz` does not match any known parameter of bound method `__init__`
  --> test.py:38:22
   |
36 | # No parameter named "bar"
37 | # No parameter named "baz"
38 | s = Struct(bar="yo", baz="something")
   |                      ^^^^^^^^^^^^^^^
39 | print(s)
   |
info: Method signature here
   --> stdlib/builtins.pyi:136:9
    |
134 |     @__class__.setter
135 |     def __class__(self, type: type[Self], /) -> None: ...
136 |     def __init__(self) -> None: ...
    |         ^^^^^^^^^^^^^^^^^^^^^^
137 |     def __new__(cls) -> Self: ...
138 |     # N.B. `object.__setattr__` and `object.__delattr__` are heavily special-cased by type checkers.
    |
info: rule `unknown-argument` is enabled by default

error[too-many-positional-arguments]: Too many positional arguments to bound method `__init__`: expected 1, got 3
  --> test.py:42:12
   |
41 | # Expected 0 positional arguments
42 | s = Struct("yo", "something")
   |            ^^^^
43 | print(s)
   |
info: Method signature here
   --> stdlib/builtins.pyi:136:9
    |
134 |     @__class__.setter
135 |     def __class__(self, type: type[Self], /) -> None: ...
136 |     def __init__(self) -> None: ...
    |         ^^^^^^^^^^^^^^^^^^^^^^
137 |     def __new__(cls) -> Self: ...
138 |     # N.B. `object.__setattr__` and `object.__delattr__` are heavily special-cased by type checkers.
    |
info: rule `too-many-positional-arguments` is enabled by default

error[unresolved-attribute]: Class `Struct` has no attribute `lorem`
  --> test.py:46:5
   |
45 | # Cannot access attribute "lorem" for class "type[Struct]"
46 | s = Struct.lorem(bar="yo", baz="something")
   |     ^^^^^^^^^^^^
47 | print(s)
   |
info: rule `unresolved-attribute` is enabled by default

Found 4 diagnostics

What can I do to help type checkers understand this?

As of now there’s no way to annotate this. What you’re looking for is intersection types.

That’s too bad.

Thanks for putting a word to the concept, at least.

Looks like ty at least supports the concept of intersections, but seems like it doesn’t work in a decorator, alas.

1 Like

A decorator that dynamically adds a new base class is not something the type system will realistically support. Intersection types might get you a little closer but type checkers still won’t know that you changed the base classes.

Is this a stripped down example of something in which the decorator foo does other stuff too? I don’t expect Mypy to unpick cls = type(cls.__name__, (MyBase,) + cls.__bases__, dict(cls.__dict__)), that’s just classic dynamic typing wizardry, not static typing predictability.

What’re the major disadvantages of the normal mixin pattern:

@dataclass
class Struct(MyBase):
    bar: str
    baz: int

over using the decorator:

@foo
class Struct:
    bar: str
    baz: int

If you have static type checking anyway, to make sure anything that calls .lorem and ipsum only does so on instances of MyBase, what extra value is the run time check adding: if MyBase not in cls.__bases__: ?

the decorator doesn’t do anything else in the real code, but it was otherwise stripped down (just removing extraneous code unrelated to the decorator). The decorator was __call__ for convenience of having one thing to import.

What’re the major disadvantages of the normal mixin pattern

really, just the aesthetics of being able to do everything with one import :P. I’ve gone to using the normal pattern now.

2 Likes