2025, Nov 16 19:00

Stop Duplicating Types in Python Wrappers: Use a Metaclass and __orig_bases__ to Enforce API Parity

Enforce wrapper API parity in Python with a metaclass that infers the generic target via __orig_bases__, eliminating duplicate type args and boilerplate.

When you build wrapper types around existing classes in Python, it’s common to want two things at once: static typing that tracks which class is being wrapped and a runtime check that enforces parity with the wrapped API. A straightforward approach forces you to specify the same type twice — once as a generic type argument for the type checker and once as a runtime argument for a metaclass that validates implementations. That duplication is noisy and fragile. The goal is to declare the type once and let the runtime logic infer it.

Problem statement

The wrapper must implement every public attribute of a wrapped class. The metaclass performs the enforcement, while the generic parameter tells the type checker what’s being wrapped. The friction appears because the type must be provided twice, which invites drift.

class MetaHook(type):
    def __new__(
        mcls,
        cls_name: str,
        parents: tuple[type, ...],
        namespace: dict[str, object],
        target_cls: type,
    ):
        for attr_name in target_cls.__dict__:
            if attr_name.startswith("__"):
                continue
            if attr_name not in namespace:
                raise TypeError(f"Need to implement {attr_name}")
        return super().__new__(mcls, cls_name, parents, namespace)
class Model:
    def foo(self):
        return "bar"
class Proxy[U](metaclass=MetaHook, target_cls=object):
    def do_proxy_work(self, x: U) -> U:
        return x
class ModelProxy(Proxy[Model], target_cls=Model): ...  # TypeError: "Need to implement foo"

This works mechanically but requires repeating the type both as a generic argument and as a metaclass argument, which is exactly what we want to avoid.

Why the duplication happens

Generic arguments exist for the type checker and aren’t meant to drive runtime behavior directly. However, the class definition still records how generics were parameterized. Python keeps the original base class expressions you wrote in a special attribute on the class namespace during creation. That’s enough to recover the concrete type argument and use it in the metaclass, removing the need for a separate runtime-only parameter.

The fix: infer the wrapped class from the declared generic base

The approach is to read the declared base classes as written, pull out the type parameter, and run the enforcement against that. There’s one nuance: when defining the generic wrapper itself, the base typing.Generic is implicitly inserted; you should skip checks in that case.

from typing import Any, Generic
class MetaHook(type):
    def __new__[MC: MetaHook](
        mcls: type[MC],
        cls_name: str,
        parents: tuple[type, ...],
        namespace: dict[str, Any],
    ) -> MC:
        # Skip validation for the generic wrapper skeleton itself
        if parents and parents[0] is Generic:
            return super().__new__(mcls, cls_name, parents, namespace)
        # Recover the exact base expression(s) as declared, e.g. (Proxy[Model],)
        orig = namespace.get("__orig_bases__")
        if orig is not None:
            wrapped = orig[0].__args__[0]
        else:
            # No parameterised generic; nothing to validate
            return super().__new__(mcls, cls_name, parents, namespace)
        for attr_name in wrapped.__dict__:
            if attr_name.startswith("__"):
                continue
            if attr_name not in namespace:
                raise TypeError(f"Need to implement {attr_name}")
        return super().__new__(mcls, cls_name, parents, namespace)
class Model:
    def foo(self) -> str:
        return "bar"
class Proxy[U](metaclass=MetaHook):
    def do_proxy_work(self, x: U) -> U:
        return x
class ModelProxy(Proxy[Model]): ...  # TypeError: Need to implement foo

Now the metaclass derives the wrapped class from the generic base via __orig_bases__, and there’s no need to pass a separate runtime argument. The parity check still fires if the wrapper misses an attribute.

What’s really going on

At class creation time, the namespace includes an __orig_bases__ tuple that retains the original base declarations, including generic parameters. Extracting the first base and its first argument is enough for the single-wrapper scenario. The special-casing for typing.Generic avoids running validation when defining the generic wrapper itself, where no concrete type is available yet.

Why this matters

Removing duplicate type specifications reduces drift between static and runtime intent. In setups where wrappers enforce API parity and control access, a single source of truth for the wrapped type keeps the model consistent and avoids silent mismatches. It also keeps the type checker aligned with what actually happens at runtime without extra boilerplate.

Conclusion

If you need a metaclass to enforce that a wrapper matches its target API, you can infer the concrete type parameter from the class’s declared generic base using __orig_bases__. That eliminates the second, runtime-only argument and streamlines both the ergonomics and the correctness of your design. Be mindful that narrowing down the right base can get more involved when multiple base classes are parameterized, but for the straightforward case this approach keeps your typing and runtime behavior in sync with minimal ceremony.