ruff/crates/ty_python_semantic/resources/mdtest/dataclasses/fields.md
David Peter c8133104e8
[ty] Use field-specifier return type as the default type for the field (#20915)
## Summary

`dataclasses.field` and field-specifier functions of commonly used
libraries like `pydantic`, `attrs`, and `SQLAlchemy` all return the
default type for the field (or `Any`) instead of an actual `Field`
instance, even if this is not what happens at runtime. Let's make use of
this fact and assume that *all* field specifiers return the type of the
default value of the field.

For standard dataclasses, this leads to more or less the same outcome
(see test diff for details), but this change is important for 3rd party
dataclass-transformers.

## Test Plan

Tested the consequences of this change on the field-specifiers branch as
well.
2025-10-16 13:13:45 +02:00

3.4 KiB

Dataclass fields

Basic

from dataclasses import dataclass, field

@dataclass
class Member:
    name: str
    role: str = field(default="user")
    tag: str | None = field(default=None, init=False)

# revealed: (self: Member, name: str, role: str = str) -> None
reveal_type(Member.__init__)

alice = Member(name="Alice", role="admin")
reveal_type(alice.role)  # revealed: str
alice.role = "moderator"

# `tag` is marked as `init=False`, so this is an
# error: [unknown-argument] "Argument `tag` does not match any known parameter"
bob = Member(name="Bob", tag="VIP")

default_factory

The default_factory argument can be used to specify a callable that provides a default value for a field:

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Data:
    content: list[int] = field(default_factory=list)
    timestamp: datetime = field(default_factory=datetime.now, init=False)

# revealed: (self: Data, content: list[int] = list[int]) -> None
reveal_type(Data.__init__)

data = Data([1, 2, 3])
reveal_type(data.content)  # revealed: list[int]
reveal_type(data.timestamp)  # revealed: datetime

kw_only

[environment]
python-version = "3.12"

If kw_only is set to True, the field can only be set using keyword arguments:

from dataclasses import dataclass, field

@dataclass
class Person:
    name: str
    age: int | None = field(default=None, kw_only=True)
    role: str = field(default="user", kw_only=True)

# TODO: this would ideally show a default value of `None` for `age`
# revealed: (self: Person, name: str, *, age: int | None = int | None, role: str = str) -> None
reveal_type(Person.__init__)

alice = Person(role="admin", name="Alice")

# error: [too-many-positional-arguments] "Too many positional arguments: expected 1, got 2"
bob = Person("Bob", 30)

The field function

from dataclasses import field

def get_default() -> str:
    return "default"

reveal_type(field(default=1))  # revealed: dataclasses.Field[Literal[1]]
reveal_type(field(default=None))  # revealed: dataclasses.Field[None]
# TODO: this could ideally be `dataclasses.Field[str]` with a better generics solver
reveal_type(field(default_factory=get_default))  # revealed: dataclasses.Field[Unknown]

dataclass_transform field_specifiers

If field_specifiers is not specified, it defaults to an empty tuple, meaning no field specifiers are supported and dataclasses.field and dataclasses.Field should not be accepted by default.

from typing_extensions import dataclass_transform
from dataclasses import field, dataclass
from typing import TypeVar

T = TypeVar("T")

@dataclass_transform()
def create_model(*, init: bool = True):
    def deco(cls: type[T]) -> type[T]:
        return cls
    return deco

@create_model()
class A:
    name: str = field(init=False)

# field(init=False) should be ignored for dataclass_transform without explicit field_specifiers
reveal_type(A.__init__)  # revealed: (self: A, name: str) -> None

@dataclass
class B:
    name: str = field(init=False)

# Regular @dataclass should respect field(init=False)
reveal_type(B.__init__)  # revealed: (self: B) -> None

Test constructor calls:

# This should NOT error because field(init=False) is ignored for A
A(name="foo")

# This should error because field(init=False) is respected for B
# error: [unknown-argument]
B(name="foo")