2025, Nov 23 17:00

Type-safe Python descriptors in Pyright: fix dataclass initialization errors with __set__ and __get__ overloads

Fix Pyright dataclass type mismatches by making your descriptor a data-descriptor with __set__ and __set_name__, and overloading __get__ for class vs instance.

Making a Python descriptor look like one type on the class and another on the instance is a common need when you want clean ergonomics with static type checking. Overloading __get__ often gets you most of the way there, but Pyright will still complain during dataclass initialization if the descriptor isn’t a proper data-descriptor. The symptom looks like a type mismatch when passing an instance value in the constructor.

Problem

The goal is to have a class-level access that returns the descriptor object and an instance-level access that returns a concrete value. Overloading __get__ communicates this to the type checker, yet Pyright flags the dataclass initializer.

Literal[1] is not assignable to Field[int]

Reproducible example:

import typing as tp
from dataclasses import dataclass
class Cell[ValT]:
    def __init__(self, seed: ValT):
        self.seed = seed
        self.prime = True
    def __get__(self, obj, owner):
        if self.prime:
            self.prime = False
            return self.seed
        return self
    if tp.TYPE_CHECKING:
        @tp.overload
        def __get__(self, obj: None, owner: type) -> tp.Self: ...
        @tp.overload
        def __get__(self, obj: object, owner: type) -> ValT: ...
@dataclass
class Record:
    slot: Cell[int] = Cell(0)
if __name__ == "__main__":
    cls_slot: Cell = Record.slot
    inst_slot: int = Record().slot
    assert isinstance(cls_slot, Cell)
    assert isinstance(inst_slot, int)
    item = Record(slot=1)

What’s going on

The overloaded __get__ describes different return types for class and instance access, but the attribute is not a data-descriptor. To be a data-descriptor, it needs a __set__ method. Without that, Pyright won’t treat assignments through the dataclass constructor as writes to the instance value and will keep seeing the attribute as the descriptor type, leading to the reported mismatch. The accepted value type is driven by the signature of __set__, which must accept the generic instance type.

Fix and working version

Implement a proper data-descriptor by adding __set__ and using __set_name__ to store the per-instance value under a private attribute. The instance value type is then enforced through the generic parameter in __set__. The existing first-use behavior is preserved.

import typing as tp
from dataclasses import dataclass
class Cell[ValT]:
    def __init__(self, seed: ValT):
        self.seed = seed
        self.prime = True
    @tp.overload
    def __get__(self, obj: None, owner: type) -> tp.Self: ...
    @tp.overload
    def __get__(self, obj: object, owner: type) -> ValT: ...
    def __get__(self, obj, owner):
        if self.prime:
            self.prime = False
            return self.seed
        if obj is None:
            return self
        return getattr(obj, self._alias, self.seed)
    def __set_name__(self, owner, name):
        self._alias = "_" + name
    def __set__(self, obj, value: ValT):
        setattr(obj, self._alias, value)
@dataclass
class Record:
    slot: Cell[int] = Cell(0)
if __name__ == "__main__":
    cls_slot = Record.slot
    reveal_type(cls_slot)  # Cell[int]
    rec = Record()
    inst_slot: int = rec.slot
    reveal_type(inst_slot)  # int
    assert isinstance(cls_slot, Cell)
    assert isinstance(inst_slot, int)
    valid = Record(slot=1)      # OK
    invalid = Record(slot="1")  # Error

Why this matters

Descriptors that act like different types on class and instance are powerful, but static type checkers need clear signals. Ensuring the descriptor is a data-descriptor with a __set__ that accepts the generic instance type aligns runtime behavior with static expectations, so constructor arguments and attribute writes type-check as intended.

Takeaways

If Pyright treats your descriptor-annotated dataclass field as the descriptor type during initialization, make it a data-descriptor. Provide a __set__ whose value parameter matches the instance’s generic type, and, if needed, use __set_name__ to manage per-instance storage. Keep your __get__ overloads to reflect class versus instance access, and the checker will accept constructor assignments and instance reads consistently.