Is there a way for a library to declare that plain field annotations on its models stand for descriptor-backed attributes, so that class access gets the descriptor’s type? I am looking for something checkers can interpret statically from a declaration. A type-checker plugin, generated stubs, or running metaclass code would not count.
The problem
I maintain alchemic (on PyPI), which combines SQLAlchemy and Pydantic models. Its model classes use dataclass_transform, and its columns are descriptors. Reading a column from an instance gives the value; reading it from the class gives a column object that builds SQL expressions. SQLAlchemy’s Mapped[T] works the same way, and SQLModel has the same problem.
A minimal descriptor, with no dependencies (full repro at the end):
class Column(Generic[T]):
@overload
def __get__(self, instance: None, owner: type[Any]) -> Self: ...
@overload
def __get__(self, instance: object, owner: type[Any]) -> T: ...
def __set__(self, instance: object, value: T) -> None: ...
def __eq__(self, other: object) -> Expression: ...
On mypy 2.3.1, pyright 1.1.408 and ty 0.0.17, annotating the field with the descriptor type gives the result I want:
class Explicit(Model):
memo: Column[str | None] = column(default=None)
reveal_type(Explicit().memo) # str | None
reveal_type(Explicit.memo) # Column[str | None]
reveal_type(Explicit.memo == "refund") # Expression
Explicit(memo=1) # error: expected "str | None"
All three checkers take the constructor parameter type from what __set__ accepts. That matches what runtime dataclasses do with descriptor-typed fields, although the typing spec does not state it.
With a plain annotation, checkers take the annotation at its word:
class Plain(Model):
memo: str | None = None
reveal_type(Plain.memo) # str | None
reveal_type(Plain.memo == "refund") # bool
Under current rules this is correct: a class-body annotation declares the attribute’s type for both class and instance access. I am asking whether a library can declare that, on the classes it transforms, a field annotation means something different.
Why not write Column[T] everywhere?
- The same class is also a Pydantic model. There, the annotation is the validation schema, and users expect to write
memo: str | None = None. - SQLModel uses plain annotations. To keep checkers happy, its documentation tells users to wrap class access in
col()at every call site. - SQLAlchemy’s mypy plugin performed exactly this transformation: it would “accept the above
int,strandOptional[str]and convert them to include theMapped[]type surrounding them”. It is deprecated because “Mypy plugins are extremely difficult to develop, maintain and test” and the plugin “does not solve the issue of supplying correct typing with other typing tools”. SQLAlchemy 2.0 now requiresMapped[...]explicitly. The need did not go away; it lost its only mechanism.
What I tried
Each result below holds on all three checkers unless noted.
- A field specifier that returns the descriptor (
memo: str | None = column(...), wherecolumnreturnsColumn[T]) produces an incompatible-assignment error. ReturningAnyinstead removes the error, as the spec’smodel_fieldexample and Pydantic’sField()overloads do. In both cases class access staysstr | None. So the assignment diagnostic can be solved today; class access cannot. - A metaclass
__getattr__returningColumn[Any]only applies to undeclared names.Plain.memostaysstr | None. ClassVar[Column[T]]types class and instance reads correctly. However, the field drops out of__init__, soPlain(memo=...)is rejected. mypy and ty also reject assignment through an instance.- A call-site helper like SQLModel’s
col()keeps the types but has to be applied at every use. A proxy namespace (Plain.c.memo) loses per-field types unless it is generated. - A prototype
dataclass_transformparameter is not possible either. The spec says checkers “should report errors for any unrecognized parameters”, so experimenting needs checker support first.
Precedent
Dataclass semantics already give some annotations a meaning beyond “the attribute has this type”. ClassVar excludes a field, InitVar[T] makes a field init-only, and KW_ONLY is a sentinel. The Converters section of the dataclasses spec also lets a field’s constructor parameter type differ from its declared type. What I am asking for is one more rule of this kind. The library would opt in, and the rule would apply only to the classes that library transforms.
A concrete shape
# Hypothetical: field_wrapper is not part of dataclass_transform today.
@dataclass_transform(kw_only_default=True, field_specifiers=(column,), field_wrapper=Column)
class ModelMeta(type): ...
class Plain(Model):
memo: str | None = None
reveal_type(Plain().memo) # str | None
reveal_type(Plain.memo) # Column[str | None]
reveal_type(Plain.memo == "refund") # Expression
Plain(memo=1) # error: expected "str | None"
Plain().memo = 1 # error, via Column.__set__
Rules:
field_wrappernames a generic class with exactly one type parameter that implements__get__.- For each field of a transformed class with declared type
T, attribute access and assignment useWrapper[T]as the declared type. - A field whose declared type is already a descriptor is left unchanged. Explicit
Column[T]annotations keep working and never becomeColumn[Column[T]], and other descriptors, such as relationships, can still be spelled out. - Annotations that do not declare fields (
ClassVar,InitVar,KW_ONLY) are unaffected. - A plain default such as
= Noneis checked againstT, notWrapper[T]. Field specifier calls are checked as they are today. - The
__init__parameter comes fromWrapper[T]the same way checkers already handle descriptor-typed fields: it takes the type__set__accepts. A field with a converter keeps the converter’s input type. - A field specifier can opt a field out of wrapping (for example, a validation-only field) with a new standard parameter. It would use a literal-
booloverload, the same wayinitandkw_onlywork today.
Alternative: descriptors declared per field specifier
Instead of a class-wide wrapper, each field specifier could declare which descriptor it installs: mapped_column() selects Column[T], relationship() selects Relationship[T]. This makes the selection explicit per field. On its own, though, it cannot cover memo: str | None = None, which has no specifier call. A class-wide default with per-specifier overrides would cover both, at the cost of a larger change.
Questions
- Is there an existing, standardized way to declare this that I have missed?
- If not, does it belong in
dataclass_transform, or should it attach to field specifiers? - For checker maintainers: does swapping the declared type before descriptor resolution fit how your checker models dataclass fields, or is there an obstacle that the rules above miss?
Full repro (mypy 2.3.1, pyright 1.1.408, ty 0.0.17)
from typing import Any, Generic, Self, TypeVar, dataclass_transform, overload, reveal_type
T = TypeVar("T")
class Expression:
... # stands in for a SQL expression
class Column(Generic[T]):
@overload
def __get__(self, instance: None, owner: type[Any]) -> Self: ...
@overload
def __get__(self, instance: object, owner: type[Any]) -> T: ...
def __get__(self, instance: object | None, owner: type[Any]) -> Self | T:
raise NotImplementedError
def __set__(self, instance: object, value: T) -> None: ...
def __eq__(self, other: object) -> Expression: # type: ignore[override]
raise NotImplementedError
def column(*, default: Any = ...) -> Any:
raise NotImplementedError
@dataclass_transform(kw_only_default=True, field_specifiers=(column,))
class ModelMeta(type): ...
class Model(metaclass=ModelMeta): ...
class Explicit(Model):
memo: Column[str | None] = column(default=None)
class Plain(Model):
memo: str | None = None
entry = Explicit()
reveal_type(entry.memo) # str | None
reveal_type(Explicit.memo) # Column[str | None]
reveal_type(Explicit.memo == "refund") # Expression
Explicit(memo=1) # error
plain = Plain()
reveal_type(plain.memo) # str | None
reveal_type(Plain.memo) # want Column[str | None]
reveal_type(Plain.memo == "refund") # want Expression
Plain(memo=1) # error
All three checkers agree: Plain.memo is str | None and the comparison is bool.