# Plain annotations for descriptor-backed fields (ORMs, dataclass\_transform)

**URL:** <https://discuss.python.org/t/plain-annotations-for-descriptor-backed-fields-orms-dataclass-transform/109427>\
**Category:** Typing\
**Tags:** typing, annotations, typing-spec\
**Created:** [October 10, 2026, 2:03pm UTC](https://discuss.python.org/t/plain-annotations-for-descriptor-backed-fields-orms-dataclass-transform/109427 "2026-10-10T14:03:17Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![bmo](https://sea2.discourse-cdn.com/flex002/user_avatar/discuss.python.org/bmo/32/37899_2.png) [@bmo](https://discuss.python.org/u/bmo)\
**Post date:** [October 10, 2026, 2:03pm UTC](https://discuss.python.org/t/plain-annotations-for-descriptor-backed-fields-orms-dataclass-transform/109427/1 "2026-10-10T14:03:17Z")

</div>

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):

```python
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:

```python
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:

```python
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()`](https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors) at every call site.
- SQLAlchemy’s [mypy plugin](https://docs.sqlalchemy.org/en/20/orm/extensions/mypy.html) performed exactly this transformation: it would “accept the above `int`, `str` and `Optional[str]` and convert them to include the `Mapped[]` 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 requires `Mapped[...]` 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(...)`, where `column` returns `Column[T]`) produces an incompatible-assignment error. Returning `Any` instead removes the error, as the spec’s `model_field` example and Pydantic’s `Field()` overloads do. In both cases class access stays `str | None`. So the assignment diagnostic can be solved today; class access cannot.
- **A metaclass ` __getattr__ `** returning `Column[Any]` only applies to undeclared names. `Plain.memo` stays `str | None`.
- **`ClassVar[Column[T]]`** types class and instance reads correctly. However, the field drops out of ` __init__ `, so `Plain(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_transform` parameter** 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

```python
# 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:

1. `field_wrapper` names a generic class with exactly one type parameter that implements ` __get__ `.
2. For each field of a transformed class with declared type `T`, attribute access and assignment use `Wrapper[T]` as the declared type.
3. A field whose declared type is already a descriptor is left unchanged. Explicit `Column[T]` annotations keep working and never become `Column[Column[T]]`, and other descriptors, such as relationships, can still be spelled out.
4. Annotations that do not declare fields (`ClassVar`, `InitVar`, `KW_ONLY`) are unaffected.
5. A plain default such as `= None` is checked against `T`, not `Wrapper[T]`. Field specifier calls are checked as they are today.
6. The ` __init__ ` parameter comes from `Wrapper[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.
7. 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-`bool` overload, the same way `init` and `kw_only` work 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

1. Is there an existing, standardized way to declare this that I have missed?
2. If not, does it belong in `dataclass_transform`, or should it attach to field specifiers?
3. 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)**
>
> ```python
> 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`.

---

<div class="post-metadata">

**Author:** ![ImogenBits](https://sea2.discourse-cdn.com/flex002/user_avatar/discuss.python.org/imogenbits/32/29566_2.png) [@ImogenBits](https://discuss.python.org/u/ImogenBits)\
**Post date:** [October 11, 2026, 6:13pm UTC](https://discuss.python.org/t/plain-annotations-for-descriptor-backed-fields-orms-dataclass-transform/109427/2 "2026-10-11T18:13:24Z")

</div>

To me this feels like the perfect example of [PEP 827](https://peps.python.org/pep-0827/)’s motivation. We have a class definition that contains all the relevant info, but we need to fill in a bunch of boilerplate to make things “just work”. The `dataclass_transform` decorator is essentially a hard-coded set of behaviour rules that fit a reasonably common use case. I don’t think that the way to make it better and fit more use cases to add to an ever-increasing list of specific situations and exceptions. Rather, what we really want is a way for libraries to let type checkers know what metaprogramming magic they’re doing. That way we also don’t have to decide whether we want to e.g. have a class-wide descriptor or one based on field specifiers, each library author and/or user gets to use exactly what fits their use case best.

Though, of course I see that PEP 827 is a massively bigger undertaking with many more moving parts. But it’s, in my opinion, the much cleaner way to make this kind of thing possible.

---

<div class="post-metadata">

**Author:** ![bmo](https://sea2.discourse-cdn.com/flex002/user_avatar/discuss.python.org/bmo/32/37899_2.png) [@bmo](https://discuss.python.org/u/bmo)\
**Post date:** [October 12, 2026, 6:08pm UTC](https://discuss.python.org/t/plain-annotations-for-descriptor-backed-fields-orms-dataclass-transform/109427/3 "2026-10-12T18:08:29Z")

</div>

I agree that a general mechanism is the right long-term answer, and PEP 827’s motivation makes that case well. To see how close it gets here, I tried the PoC mypy fork (‘msullivan/mypy’, branch ‘typemap’, commit ‘5e9ae8d’). It can express the access typing, with ‘dataclass\_transform’ still synthesizing ‘ **init** ’ (excerpt; the full probe is at the end):

```python
type Descriptor[Type, Init] = (
    Type
    if IsAssignable[Type, Column[Any] | Relationship[Any]]
    else Relationship[Type]
    if IsAssignable[Literal["relationship"], GetMemberType[Init, Literal["kind"]]]
    else Column[Type]
)

class Model(metaclass=ModelMeta): # ModelMeta still uses dataclass_transform
    def __init_subclass__ [T](cls: type[T]) -> UpdateClass[
        *[
            Member[p.name, Descriptor[p.type, p.init], p.quals, p.init]
            for p in Iter[Attrs[T]]
            if not IsAssignable[Literal["ClassVar"], p.quals]
        ]
    ]: ...

class Parent(Model):
    memo: str | None = None
    children: list[Child] = relationship(default=[])
    registry: ClassVar[str] = "parents"

reveal_type(Parent.memo) # Column[str | None]
reveal_type(Parent().memo) # str | None
reveal_type(Parent.memo == "refund") # Expression
reveal_type(Parent.children) # Relationship[list[Child]]
reveal_type(Parent.registry) # str
Parent(memo=1) # error: expected "str | None"

```

The descriptor is chosen per field, here from the specifier, so under PEP 827 the class-wide versus specifier question from my post goes away. Plain defaults are checked against the declared type, as rule 5 in my post asks (‘memo: str | None = 1’ is rejected).

The constructor is the open part. The PEP doesn’t say how ‘UpdateClass’ combines with ‘dataclass\_transform’, and in the PoC ‘ **init** ’ uses the declared type while assignment goes through the updated descriptor. With a setter that also accepts SQL expressions (‘value: T | Expression’), ‘parent.memo = Parent.memo == “x”’ is accepted but ‘Parent(memo=Parent.memo == “x”)’ is rejected. When a field is annotated with the descriptor type, mypy 2.3.1, pyright 1.1.408 and ty 0.0.17 take the ‘ **init** ’ parameter from ‘ **set** ’ instead.

The alternative is to build ‘ **init** ’ in the same ‘UpdateClass’, as the PEP’s ‘Model’ example does. The PEP calls that example simplified (every parameter is keyword-only), and a full version would reimplement, as type-level code, the constructor rules that ‘dataclass\_transform’ already provides. ‘KW\_ONLY’ support, for one, needs a recursive type alias over the members, as noted in the PEP 827 thread.

That’s why I’d still like to discuss a narrow rule alongside PEP 827:

- **It reuses what checkers already do.** Constructor synthesis (defaults, ‘kw\_only’, ‘init=False’, aliases) stays with ‘dataclass\_transform’, which mypy, pyright and ty already implement. ‘ **init** ’ takes the ‘ **set** ’ type, as it already does for descriptor-typed fields. The new work is typing a field as ‘Column[T]’ for access and assignment while checking a plain default against ‘T’.
- **It fits PEP 827.** The ‘Descriptor’ alias above is a sketch of the class-wide rule in my post, so if PEP 827 lands, the narrow rule could be described as a transformation of that kind.
- **There’s a process for it.** ‘converter’ was added to ‘dataclass\_transform’ in 2024 by a Typing Council decision, after PEP 712 was rejected. It followed [this discussion](https://discuss.python.org/t/55125), which asked for a concrete spec change and, ideally, an implementation in at least one checker.

So, two open questions. Is combining ‘UpdateClass’ with ‘dataclass\_transform’ meant to be supported, and should ‘ **init** ’ then follow the updated descriptor? And would a narrow ‘dataclass\_transform’ rule for this be welcome in the meantime, or is it better to leave ‘dataclass\_transform’ as it is while PEP 827 develops?

> **Full probe, plus a note on InitField (run with --python-version 3.15 on the PoC fork)**
>
> The PoC’s ‘InitField’ inherits from ‘Any’ (marked TODO in its stub), so ‘IsAssignable’ can’t tell ‘relationship(…)’ apart from no initializer (‘Never’). The ‘kind’ marker on ‘relationship’ works around that.
> 
> ```python
> from typing import (
> Any, Attrs, BaseTypedDict, ClassVar, InitField, IsAssignable, GetMemberType, Iter, Literal, Member,
> NotRequired, Self, UpdateClass, dataclass_transform, overload, reveal_type,
> )
> 
> class Expression: ...
> 
> class Column[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 | Expression) -> None: ...
> 
> def __eq__ (self, other: object) -> Expression: # type: ignore[override]
> raise NotImplementedError
> 
> class Relationship[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: ...
> 
> class RelationshipArgs(BaseTypedDict):
> default: NotRequired[Any]
> 
> class relationship[KwargDict: BaseTypedDict = RelationshipArgs](InitField[KwargDict]):
> kind: Literal["relationship"]
> 
> type Descriptor[Type, Init] = (
> Type
> if IsAssignable[Type, Column[Any] | Relationship[Any]]
> else Relationship[Type]
> if IsAssignable[Literal["relationship"], GetMemberType[Init, Literal["kind"]]]
> else Column[Type]
> )
> 
> @dataclass_transform(kw_only_default=True, field_specifiers=(relationship,))
> class ModelMeta(type): ...
> 
> class Model(metaclass=ModelMeta):
> def __init_subclass__ [T](cls: type[T]) -> UpdateClass[
> *[
> Member[p.name, Descriptor[p.type, p.init], p.quals, p.init]
> for p in Iter[Attrs[T]]
> if not IsAssignable[Literal["ClassVar"], p.quals]
> ]
> ]:
> raise NotImplementedError
> 
> class Child(Model):
> name: str
> 
> class Parent(Model):
> memo: str | None = None
> children: list[Child] = relationship(default=[])
> registry: ClassVar[str] = "parents"
> 
> reveal_type(Parent.memo) # Column[str | None]
> reveal_type(Parent().memo) # str | None
> reveal_type(Parent.memo == "refund") # Expression
> reveal_type(Parent.children) # Relationship[list[Child]]
> reveal_type(Parent().children) # list[Child]
> reveal_type(Parent.registry) # str
> reveal_type(Child.name) # Column[str]
> Parent(memo=1) # error: expected "str | None"
> Parent(memo="ok", children=[Child(name="x")])
> Parent(mmo="typo") # error: unexpected keyword argument
> 
> class Explicit(Model):
> count: Column[int] = Column()
> 
> reveal_type(Explicit.count) # Column[int]
> 
> class BadDefault(Model):
> memo: str | None = 1 # error: incompatible types in assignment
> 
> parent = Parent()
> parent.memo = Parent.memo == "x" # accepted: Column. __set__ takes T | Expression
> Parent(memo=Parent.memo == "x") # error: expected "str | None"
> 
> ```
