2025, Oct 31 23:00

Type-safe dual behavior for Python attributes: using a descriptor with overloaded __get__ vs a metaclass

Learn how to model class vs instance attribute types in Python static typing using a descriptor with overloaded __get__, avoiding duplicate annotations.

Making a class attribute behave one way on the class and another way on instances is a neat trick until static typing gets involved. The practical use case here is simple: on the class, you want a lightweight marker object that carries the field name; on instances, you want the actual value. The difficulty is convincing a type checker that both views are valid without duplicating annotations.

Reproducing the setup with a metaclass

The pattern below injects per-field marker objects into the class at creation time, while instances store and return real values. It works at runtime, but a static analyzer will not naturally connect the dots and assign different types to class-level and instance-level access.

from typing import Any

class Field:
    def __init__(self, label: str) -> None:
        self.key = label

    def __repr__(self):
        return f"Field(name={self.key!r})"

class ModelMeta(type):
    def __new__(mcls, type_name: str, parents: tuple[type, ...], attrs: dict[str, Any]) -> type:
        hints = attrs.get('__annotations__', {})
        attrs.update({k: Field(k) for k in hints})
        return super().__new__(mcls, type_name, parents, attrs)
        
class RecordBase(metaclass=ModelMeta):
    def __init__(self, payload: dict[str, Any]) -> None:
        if self.__class__ == RecordBase:
            raise RuntimeError
        self.__dict__ = payload

class Person(RecordBase):
    name: str
    height: int

p = Person({'name': 'Tom', 'height': 180})

print(Person.height)  # Field(name='height')
print(p.height)       # 180

Where the type checker pushes back

The metaclass injects class attributes based on the annotations. At runtime, attribute access on the class yields marker objects, and access on instances yields the stored values. Staticaly, however, the type checker only sees that the class defines annotated attributes and assumes those annotations describe both class and instance access, unless you explicitly add separate class-level annotations like ClassVar. That leads to either incorrect types for class access or duplication of the same attribute with two annotations, which is what we want to avoid.

The workable approach: descriptors with overloads

The way to express “class access returns one type, instance access returns another” is to route access through a descriptor and overload its __get__ method. The overloads tell the type checker that when the descriptor is accessed on the class, it returns a marker object, and when accessed on an instance, it returns the specific annotated value type. The runtime behavior matches the metaclass-based pattern, but the typing model becomes precise.

from typing import Any, Callable, overload, Never

class Marker:
    def __init__(self, title: str) -> None:
        self.title = title
    
    def __repr__(self) -> str:
        return f"Marker(name={self.title})"


class CoreRow:
    __needed__: set[str] = set()

    def __init__(self, record: dict[str, Any]) -> None:
        if self.__class__ == CoreRow:
            raise RuntimeError

        self.__stash__ = record


class Slot[TR: CoreRow, TV: Any]:
    def __init__(self, fn: Callable[[TR], TV] | Callable[[type[TR]], TV]):
        self._inner = fn
    
    def __set_name__(self, owner: type[TR], attr: str):
        self.attr = attr
        self.flag = Marker(attr)

        if not owner.__needed__:
            owner.__needed__ = set()

        owner.__needed__.add(attr)

    @overload
    def __get__(self, instance: TR, owner: type[TR]) -> TV:
        ...
    
    @overload
    def __get__(self, instance: None, owner: type[TR]) -> Marker:
        ...

    def __get__(self, instance: TR | None, owner: type[TR]) -> TV | Marker:
        if instance is None:
            return self.flag
        
        return instance.__stash__[self.attr]

# The body of a Slot is never executed at runtime in this pattern
# and we use Never to satisfy the type checker.
def abort() -> Never:
    raise RuntimeError()
    
class User(CoreRow):
    @Slot 
    def name(self_or_cls) -> str: 
        abort()

    @Slot
    def height(self_or_cls) -> int:
        abort()

u = User({'name': 'Tom', 'height': 180})

print(User.height)  # Marker(name=height)
print(u.height)     # 180

The overloads on __get__ make the intent explicit: instance is None corresponds to class-level access and returns a Marker; otherwise it returns the stored value. The descriptor’s __set_name__ hook binds the attribute name, creates the marker, and tracks keys required in the backing mapping.

Why this matters

This approach keeps the ergonomics of a declarative field list while giving static analysis accurate information. You avoid duplicating annotations for the same attribute, get correct types for both class and instance access, and preserve the runtime behavior where class attributes are marker objects and instances expose real values.

Takeaways

When you need dual behavior for the same attribute, a descriptor with precisely overloaded __get__ aligns runtime semantics and static typing. The metaclass that injects markers is convenient but opaque to type checkers; moving the logic into a descriptor makes the distinction between class-level and instance-level access explicit and keeps the codebase clearer for tools and humans alike.

The article is based on a question from StackOverflow by JoniKauf and an answer by JoniKauf.