Skip to content

[ty] Support dataclass_transform as a function call#22378

Merged
charliermarsh merged 3 commits intomainfrom
charlie/class-x
Jan 10, 2026
Merged

[ty] Support dataclass_transform as a function call#22378
charliermarsh merged 3 commits intomainfrom
charlie/class-x

Conversation

@charliermarsh
Copy link
Member

Summary

Instead of just as a decorator.

Closes astral-sh/ty#2319.

@charliermarsh charliermarsh added bug Something isn't working ty Multi-file analysis & type inference labels Jan 4, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 4, 2026

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Jan 4, 2026

mypy_primer results

Changes were detected when running on open source projects
attrs (https://github.com/python-attrs/attrs)
- tests/test_annotations.py:644:13: error[invalid-assignment] Object of type `<decorator produced by dataclass-like function>` is not assignable to `<class 'C'>`
+ tests/test_annotations.py:644:13: error[invalid-assignment] Object of type `<class 'tests.test_annotations.TestAnnotations.<locals of function 'test_init_type_hints_fake_module'>.C @ tests/test_annotations.py:640'>` is not assignable to `<class 'tests.test_annotations.TestAnnotations.<locals of function 'test_init_type_hints_fake_module'>.C @ tests/test_annotations.py:640'>`
- tests/test_make.py:620:13: error[invalid-assignment] Object of type `<decorator produced by dataclass-like function>` is not assignable to `<class 'C'>`
+ tests/test_make.py:620:13: error[invalid-assignment] Object of type `<class 'tests.test_make.TestAttributes.<locals of function 'test_adds_all_by_default'>.C @ tests/test_make.py:615'>` is not assignable to `<class 'tests.test_make.TestAttributes.<locals of function 'test_adds_all_by_default'>.C @ tests/test_make.py:615'>`
+ tests/test_make.py:675:13: error[invalid-assignment] Object of type `<class 'tests.test_make.TestAttributes.<locals of function 'test_respects_init_attrs_init'>.C @ tests/test_make.py:672'>` is not assignable to `<class 'tests.test_make.TestAttributes.<locals of function 'test_respects_init_attrs_init'>.C @ tests/test_make.py:672'>`
+ tests/test_make.py:2872:17: error[invalid-assignment] Object of type `type[tests.test_make.TestAutoDetect.<locals of function 'test_total_ordering'>.C @ tests/test_make.py:2858] | type[tests.test_make.TestAutoDetect.<locals of function 'test_total_ordering'>.C @ tests/test_make.py:2858]` is not assignable to `<class 'tests.test_make.TestAutoDetect.<locals of function 'test_total_ordering'>.C @ tests/test_make.py:2858'>`
+ tests/test_make.py:2882:16: error[unsupported-operator] Operator `<` is not supported between two objects of type `C | @Todo`
+ tests/test_make.py:2887:16: error[unsupported-operator] Operator `>` is not supported between two objects of type `C | @Todo`
+ tests/test_slots.py:217:10: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`, found `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`
+ tests/test_slots.py:222:9: error[unresolved-attribute] Unresolved attribute `t` on type `SimpleOrdinaryClass`.
+ tests/test_slots.py:224:17: error[invalid-argument-type] Argument to bound method `method` is incorrect: Expected `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`, found `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`
+ tests/test_slots.py:225:27: error[invalid-argument-type] Argument to bound method `classmethod` is incorrect: Expected `type[tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193]`, found `type[tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193]`
+ tests/test_slots.py:228:50: error[unresolved-attribute] Class `SimpleOrdinaryClass` has no attribute `__slots__`
+ tests/test_slots.py:230:10: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`, found `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`
+ tests/test_slots.py:232:11: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`, found `tests.test_slots.<locals of function 'test_nonslots_these'>.SimpleOrdinaryClass @ tests/test_slots.py:193`
- Found 616 diagnostics
+ Found 627 diagnostics

Tanjun (https://github.com/FasterSpeeding/Tanjun)
- tanjun/dependencies/data.py:347:12: error[invalid-return-type] Return type does not match returned value: expected `_T@cached_inject`, found `_T@cached_inject | Coroutine[Any, Any, _T@cached_inject | Coroutine[Any, Any, _T@cached_inject]]`
+ tanjun/dependencies/data.py:347:12: error[invalid-return-type] Return type does not match returned value: expected `_T@cached_inject`, found `Coroutine[Any, Any, _T@cached_inject | Coroutine[Any, Any, _T@cached_inject]] | _T@cached_inject`

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Series[Any, Any]] | ndarray[Never, Never] | ... omitted 6 union elements, object_]`
+ static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Index[Any]] | Bottom[Series[Any, Any]] | ... omitted 6 union elements, object_]`
- static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Bus[Any] | Bottom[Index[Any]] | TypeBlocks | ... omitted 6 union elements, object_ | Self@iloc]`
+ static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Bus[Any] | ndarray[Never, Never] | TypeBlocks | ... omitted 6 union elements, object_ | Self@iloc]`
- static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | Bottom[Index[Any]] | TypeBlocks | ... omitted 6 union elements, TVDtype@Series]`
- static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | Bottom[Index[Any]] | TypeBlocks | ... omitted 7 union elements, TVDtype@SeriesHE]`
+ static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | ndarray[Never, Never] | TypeBlocks | ... omitted 7 union elements, TVDtype@SeriesHE]`
- Found 1840 diagnostics
+ Found 1839 diagnostics

No memory usage changes detected ✅

@charliermarsh
Copy link
Member Author

(The hydra-zen diagnostic needs work.)


// If the return type is class-like and the first argument is a
// class, return it with dataclass params applied. Otherwise,
// return a `DataclassDecorator` for application to a class later.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is wrong. It's intended to address cases like:

@dataclass_transform()
def hydrated_dataclass(target: type, *, frozen: bool = False) -> Callable[[type[T]], type[T]]:
    def decorator(cls: type[T]) -> type[T]:
        return cls
    return decorator

@hydrated_dataclass(SomeConfig, frozen=True)
class MyConfig:
    pass

If we make this change without this declared return type handling, then hydrated_dataclass(SomeConfig, frozen=True) returns type[SomeConfig] with dataclass params, and we apply that as a decorator.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unfortunate ambiguity baked directly into the dataclass_transform spec. It doesn't clarify how we are supposed to tell whether the @dataclass_transform decorated function is a decorator or a decorator factory (yet it clearly specifies that both should be supported). I think the spec's assumption is that it doesn't matter, because only decorator-syntax usage (with @) is supported, and in that case you can tell how it is used (are there parentheses or not). But we are trying to do something more sophisticated here, and I think it means we have to resort to some kind of heuristic.

But I'm not sure the heuristic should depend on the annotated return type like this; there's no real requirement to annotate your dataclass_transform decorator at all. I think a better heuristic might be based solely on the number, kind, and type of arguments. If only one positional argument is given and it's a class type, assume we should decorate that class. Otherwise, assume we are returning the real decorator.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Okay this is very comforting to hear, haha.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that wouldn't work for this case, since both functions take a single class as its positional arguments:

from typing_extensions import dataclass_transform

@dataclass_transform()
def hydrated_dataclass[T](target: type[T], *, frozen: bool = False):
    def decorator[U](cls: type[U]) -> type[U]:
        return cls
    return decorator

For now, I combined the two heuristics.

@charliermarsh charliermarsh marked this pull request as ready for review January 5, 2026 01:28
Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks pretty good! We are definitely being more ambitious than other type checkers here (and more ambitious than the spec requires), by supporting non-decorator-syntax usages of dataclass_transform at all. Our initial implementation of dataclass_transform kind of set us on this path, by implementing the logic generally in function-call-binding and having a "DataclassTransformer" type, rather than doing everything as a special case in decorator application. And this PR seems pretty good, so I don't see much harm in continuing on this path -- it's cool if we can support non-decorator-syntax usage like this.

A few comments inline.

Comment on lines +1104 to +1124
### Passing a specialized generic class

When calling a `@dataclass_transform()` decorated function with a specialized generic class, the
specialization should be preserved.

```py
from typing_extensions import dataclass_transform

@dataclass_transform()
def my_dataclass[T](cls: type[T]) -> type[T]:
return cls

class A[T]:
x: T

B = my_dataclass(A[int])

reveal_type(B) # revealed: <class 'A[int]'>

B(1)
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm impressed that you support this, but I'm curious where it came up? Did you see this in the ecosystem?

This won't work at runtime with stdlib dataclass -- it expects to get a class object, not a typing.GenericAlias. But I suppose there could be third-party dataclass-transforms that are built to handle GenericAlias at runtime?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly can't remember -- I may have asked Claude to add something to verify that we preserve specialization after seeing handling for it in the code?!

class Target:
pass

decorator = hydrated_dataclass(Target)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about then using this like MyClass = decorator(SomeClass)?

Doesn't seem to work, even on this branch:

class M1:
    x: int

M2 = decorator(M1)

reveal_type(M2)  # reveals `type[M1] & Any`

And I'm not sure where the & Any comes from?

(As noted above, I don't think any other type checker supports this non-decorator use of a dataclass transform at all, so I don't know that we need to support this edge case, just curious why it doesn't work when the base case above does.)

Comment on lines +1043 to +1056
from typing_extensions import dataclass_transform

@dataclass_transform()
def my_dataclass[T](cls: type[T]) -> type[T]:
return cls

class A:
x: int

B = my_dataclass(A)

reveal_type(B) # revealed: <class 'A'>

B(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't seem like any other type checkers support this, but it's cool that we can.

let (implementation, overloads) = self.overloads_and_implementation(db);
overloads.into_iter().chain(implementation.iter().copied())
) -> impl DoubleEndedIterator<Item = OverloadLiteral<'db>> + 'db {
let (overloads, implementation) = self.overloads_and_implementation(db);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol, oops

I guess no other caller cared about the ordering here?

Looking at the usage in infer_class_definition, I think we might need a .rev() call there also, to make the behavior match the comment?


// If the return type is class-like and the first argument is a
// class, return it with dataclass params applied. Otherwise,
// return a `DataclassDecorator` for application to a class later.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unfortunate ambiguity baked directly into the dataclass_transform spec. It doesn't clarify how we are supposed to tell whether the @dataclass_transform decorated function is a decorator or a decorator factory (yet it clearly specifies that both should be supported). I think the spec's assumption is that it doesn't matter, because only decorator-syntax usage (with @) is supported, and in that case you can tell how it is used (are there parentheses or not). But we are trying to do something more sophisticated here, and I think it means we have to resort to some kind of heuristic.

But I'm not sure the heuristic should depend on the annotated return type like this; there's no real requirement to annotate your dataclass_transform decorator at all. I think a better heuristic might be based solely on the number, kind, and type of arguments. If only one positional argument is given and it's a class type, assume we should decorate that class. Otherwise, assume we are returning the real decorator.

@charliermarsh charliermarsh merged commit 046c5a4 into main Jan 10, 2026
49 checks passed
@charliermarsh charliermarsh deleted the charlie/class-x branch January 10, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Incorrect type inference with dataclass_transform and generics / decorators

2 participants