crates/ty_python_semantic/resources/mdtest/class/slots.md
Classes can declare instance attributes and restrict their instance layout with __slots__.
A slot is a valid instance attribute even when no method assigns to it. It can be read and assigned without a type error, even though its type may be unknown.
class Slotted:
__slots__ = ("value",)
reveal_type(Slotted().value) # revealed: Unknown
Slotted().value = 1
Accessing a slot on the class returns a MemberDescriptorType descriptor. This descriptor is not a
property and does not expose property attributes.
class Slotted:
__slots__ = ("value",)
reveal_type(Slotted.value) # revealed: MemberDescriptorType
def accepts_property(descriptor: property) -> None: ...
accepts_property(Slotted.value) # error: [invalid-argument-type]
Slotted.value.fget # error: [unresolved-attribute]
The name of a slot is included when looking up the available attributes of its class or an instance.
from ty_extensions import static_assert
from ty_extensions._internal import has_member
class Slotted:
__slots__ = ("value",)
static_assert(has_member(Slotted, "value"))
static_assert(has_member(Slotted(), "value"))
A slot descriptor can be assigned to its public MemberDescriptorType annotation. Calling its
__get__ method directly then uses the return type declared in typeshed, even when the slot has a
more precise annotation.
from types import MemberDescriptorType
class Slotted:
value: int
__slots__ = ("value",)
descriptor: MemberDescriptorType = Slotted.value
reveal_type(descriptor.__get__(Slotted(), Slotted)) # revealed: Any
# TODO: Preserve the slot annotation when its descriptor is called directly.
inferred_descriptor = Slotted.value
reveal_type(inferred_descriptor.__get__(Slotted(), Slotted)) # revealed: Any
An instance dictionary slot must not replace the existing namespace exposed by its class.
class WithDictionary:
__slots__ = ("value", "__dict__")
reveal_type(WithDictionary.__dict__) # revealed: dict[str, Any]
A subclass continues to expose its own class namespace.
class SlottedChild(WithDictionary):
__slots__ = ()
reveal_type(SlottedChild.__dict__) # revealed: dict[str, Any]
The same rule applies when a class is accessed through a type annotation.
def inspect_class(cls: type[WithDictionary]) -> None:
reveal_type(cls.__dict__) # revealed: dict[str, Any]
Assignments to slotted attributes continue to determine their inferred types.
class Slotted:
__slots__ = ("value",)
def __init__(self, value: int) -> None:
self.value = value
reveal_type(Slotted(1).value) # revealed: int
Writing to an annotated slot narrows later reads, just as it does for an ordinary instance attribute.
class Slotted:
__slots__ = ("value",)
def __init__(self) -> None:
self.value: int | None = None
def assign(instance: Slotted) -> int:
instance.value = 1
reveal_type(instance.value) # revealed: Literal[1]
return instance.value
A conditional assignment also removes None from later reads.
def initialize(instance: Slotted) -> int:
if instance.value is None:
instance.value = 1
return instance.value
Narrowing does not change which values can be assigned to the declared attribute.
def reject(instance: Slotted) -> None:
instance.value = "wrong" # error: [invalid-assignment]
An annotation in the class body follows the same narrowing rules as an annotation in an initializer.
class Slotted:
__slots__ = ("value",)
value: int | None
def assign(instance: Slotted) -> int:
instance.value = 1
return instance.value
Unlike a slot, an arbitrary data descriptor can transform an assigned value in its setter. Later
reads therefore retain the return type of the descriptor's __get__ method.
class TransformingDescriptor:
def __get__(self, instance: object, owner: type | None = None) -> int | None: ...
def __set__(self, instance: object, value: int) -> None: ...
class DescriptorOwner:
__slots__ = ()
value = TransformingDescriptor()
def inspect_descriptor(owner: DescriptorOwner) -> None:
owner.value = 1
reveal_type(owner.value) # revealed: int | None
An annotation on a slot controls both attribute reads and assignments.
class Slotted:
__slots__ = ("value",)
value: int
reveal_type(Slotted.value) # revealed: MemberDescriptorType
reveal_type(Slotted().value) # revealed: int
Slotted().value = 1
Slotted().value = "wrong" # error: [invalid-assignment]
A bare stub annotation describes the value stored in a runtime slot without creating a conflicting class variable.
class BareAnnotation:
__slots__ = ("value",)
value: int
reveal_type(BareAnnotation.value) # revealed: MemberDescriptorType
reveal_type(BareAnnotation().value) # revealed: int
BareAnnotation().value = 1
BareAnnotation().value = "wrong" # error: [invalid-assignment]
An ellipsis placeholder in a stub has the same meaning and does not conflict with its slot.
class EllipsisAnnotation:
__slots__ = ("value",)
value: str = ...
reveal_type(EllipsisAnnotation().value) # revealed: str
EllipsisAnnotation().value = "valid"
EllipsisAnnotation().value = 1 # error: [invalid-assignment]
A slot in a generic class uses the type arguments of the instance.
from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]):
__slots__ = ("value",)
value: T
def __init__(self, value: T) -> None:
self.value = value
reveal_type(Box(1).value) # revealed: int
Box(1).value = "wrong" # error: [invalid-assignment]
Slot descriptors support deleting their stored values as well as reading and writing them.
class Slotted:
__slots__ = ("value",)
instance = Slotted()
instance.value = 1
del instance.value
A single string declares one slot.
class StringSlots:
__slots__ = "value"
reveal_type(StringSlots().value) # revealed: Unknown
A tuple can declare multiple slots.
class TupleSlots:
__slots__ = ("first", "second")
reveal_type(TupleSlots().first) # revealed: Unknown
reveal_type(TupleSlots().second) # revealed: Unknown
A list can also provide the slot names.
class ListSlots:
__slots__ = ["value"]
reveal_type(ListSlots().value) # revealed: Unknown
A set can provide the slot names as its elements.
class SetSlots:
__slots__ = {"value"}
reveal_type(SetSlots().value) # revealed: Unknown
When __slots__ is a dictionary, its keys are the slot names.
class DictionarySlots:
__slots__ = {"value": "Documentation for the slot."}
reveal_type(DictionarySlots().value) # revealed: Unknown
An annotation on __slots__ does not hide its runtime value.
class AnnotatedSlots:
__slots__: tuple[str, ...] = ("value",)
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
reveal_type(AnnotatedSlots().value) # revealed: Unknown
AnnotatedSlots().missing # error: [unresolved-attribute]
A statically known tuple can also be supplied through another variable.
slot_names = ("value",)
class IndirectSlots:
__slots__ = slot_names
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
reveal_type(IndirectSlots().value) # revealed: Unknown
IndirectSlots().missing # error: [unresolved-attribute]
The elements of mutable slot declarations can also refer to statically known string values.
slot_name = "value"
class IndirectListSlots:
__slots__ = [slot_name]
reveal_type(IndirectListSlots().value) # revealed: Unknown
IndirectListSlots().missing # error: [unresolved-attribute]
The same inference applies to set elements.
class IndirectSetSlots:
__slots__ = {slot_name}
reveal_type(IndirectSetSlots().value) # revealed: Unknown
IndirectSetSlots().missing # error: [unresolved-attribute]
Dictionary keys are evaluated in the same way.
class IndirectDictionarySlots:
__slots__ = {slot_name: "Documentation for the slot."}
reveal_type(IndirectDictionarySlots().value) # revealed: Unknown
IndirectDictionarySlots().missing # error: [unresolved-attribute]
Slot names are taken from the original literal. Later changes to that literal are not evaluated, so an appended name is not treated as an available slot.
class MutatedSlots:
__slots__ = ["value"]
# TODO: Warn that mutating the slot declaration is not supported.
__slots__.append("extra")
def __init__(self) -> None:
self.value = 1
self.extra = 2 # error: [unresolved-attribute]
When the slot names cannot be determined statically, attribute writes remain permissive.
def choose_slots() -> tuple[str, ...]:
return ("value",)
class DynamicSlots:
__slots__ = choose_slots()
def __init__(self) -> None:
# No error on either assignment because the slot names are unknown.
self.value = 1
self.extra = 2
reveal_type(DynamicSlots().extra) # revealed: int
A slotted subclass can use slots declared by any of its base classes.
class Base:
__slots__ = ("base_value",)
class Child(Base):
__slots__ = ("child_value",)
def __init__(self) -> None:
self.base_value = 1
self.child_value = 2
reveal_type(Child.base_value) # revealed: MemberDescriptorType
reveal_type(Child().base_value) # revealed: int
A class with empty __slots__ can declare an instance attribute without providing a slot for it.
The annotation alone does not make the attribute writable.
class Base:
__slots__ = ()
value: int
Base().value = 1 # error: [missing-slot]
A subclass can create the missing slot. Its inherited annotation controls both reads and writes.
class Child(Base):
__slots__ = ("value",)
item = Child()
reveal_type(item.value) # revealed: int
item.value = 1
item.value = "wrong" # error: [invalid-assignment]
A generic base class supplies the type chosen by its subclass.
from typing import Generic, TypeVar
T = TypeVar("T")
class GenericBase(Generic[T]):
__slots__ = ()
value: T
class IntegerChild(GenericBase[int]):
__slots__ = ("value",)
reveal_type(IntegerChild().value) # revealed: int
IntegerChild().value = "wrong" # error: [invalid-assignment]
A subclass can narrow an inherited attribute declaration even when its storage remains in a base class's slot. Reads and writes use the subclass's declared type, as they do without slots.
class Base:
__slots__ = ("value",)
value: int | None
class Child(Base):
__slots__ = ()
# TODO: Reject this unsafe override when mutable attribute overrides are checked.
value: int
reveal_type(Child().value) # revealed: int
Child().value = 2
Child().value = None # error: [invalid-assignment]
An annotation on an assignment in the subclass's initializer establishes the same narrower type.
class InitializedChild(Base):
def __init__(self) -> None:
# TODO: Reject this unsafe override when mutable attribute overrides are checked.
self.value: int = 1
def get(self) -> int:
return self.value
reveal_type(InitializedChild().value) # revealed: int
InitializedChild().value = None # error: [invalid-assignment]
As with an ordinary instance attribute, an overriding annotation replaces the inherited type.
class StringChild(Base):
def __init__(self) -> None:
# TODO: Reject this unsafe override when mutable attribute overrides are checked.
self.value: str = "valid"
reveal_type(StringChild().value) # revealed: str
StringChild().value = 1 # error: [invalid-assignment]
An instance without an instance dictionary cannot create attributes outside its declared slots.
from typing import ClassVar
class Slotted:
__slots__ = ("value",)
shared = 1
explicit_classvar: ClassVar[int] = 2
def __init__(self) -> None:
self.value = 1
self.extra = 2 # error: [unresolved-attribute]
Slotted().other = 3 # error: [unresolved-attribute]
Slotted().shared = 3 # error: [missing-slot]
reveal_type(Slotted.value) # revealed: MemberDescriptorType
reveal_type(Slotted.shared) # revealed: int
reveal_type(Slotted.explicit_classvar) # revealed: int
Slotted.explicit_classvar = 4
An explicit __dict__ slot restores support for additional instance attributes.
class WithDictionary:
__slots__ = ("value", "__dict__")
def __init__(self) -> None:
self.extra = 1
reveal_type(WithDictionary().value) # revealed: Unknown
reveal_type(WithDictionary().extra) # revealed: int
An ordinary base class can also supply an inherited instance dictionary.
class OrdinaryBase:
pass
class InheritedDictionary(OrdinaryBase):
# TODO: Warn that these slots do not remove OrdinaryBase's instance dictionary.
__slots__ = ("value",)
def __init__(self) -> None:
# No error because OrdinaryBase provides an instance dictionary.
self.extra = 1
reveal_type(InheritedDictionary().extra) # revealed: int
A subclass without its own __slots__ regains an instance dictionary.
class SlottedBase:
__slots__ = ("value",)
class OrdinaryChild(SlottedBase):
def __init__(self) -> None:
self.extra = 1
reveal_type(OrdinaryChild().extra) # revealed: int
A named tuple synthesizes empty __slots__, so a subclass with its own empty slots does not gain an
instance dictionary. An annotation alone cannot provide storage for a new attribute.
from typing import NamedTuple
class Point(NamedTuple):
value: int
class SlottedPoint(Point):
__slots__ = ()
extra: int
SlottedPoint(1).extra = 2 # error: [missing-slot]
Named tuples created with the functional syntax have the same empty-slot layout.
FunctionalPoint = NamedTuple("FunctionalPoint", [("value", int)])
class SlottedFunctionalPoint(FunctionalPoint):
__slots__ = ()
extra: int
SlottedFunctionalPoint(1).extra = 2 # error: [missing-slot]
The collections.namedtuple factory also creates a class without an instance dictionary.
from collections import namedtuple
LegacyPoint = namedtuple("LegacyPoint", ["value"])
class SlottedLegacyPoint(LegacyPoint):
__slots__ = ()
extra: int
SlottedLegacyPoint(1).extra = 2 # error: [missing-slot]
A dataclass with slots=True does not give its instances a dictionary.
from dataclasses import dataclass
@dataclass(slots=True)
class SlottedDataclass:
value: int
SlottedDataclass(1).extra = 1 # error: [unresolved-attribute]
Its subclasses inherit that restricted instance layout unless they introduce a dictionary.
class SlottedChild(SlottedDataclass):
__slots__ = ("other",)
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
A slotted dataclass creates descriptors only for fields that do not already have an inherited slot.
from dataclasses import dataclass
@dataclass(slots=True)
class Parent:
value: int
@dataclass(slots=True)
class Child(Parent):
other: int
reveal_type(Child.__slots__) # revealed: tuple[Literal["other"]]
Redeclaring an inherited field does not create a second slot for that field.
@dataclass(slots=True)
class Redefined(Parent):
value: int
other: int
reveal_type(Redefined.__slots__) # revealed: tuple[Literal["other"]]
An inherited field still needs a new slot when its original class stored the field in an instance dictionary.
@dataclass
class UnslottedParent:
value: int
# TODO: Warn that slots=True cannot remove the inherited instance dictionary.
@dataclass(slots=True)
class SlottedChild(UnslottedParent):
other: int
def initialize(self) -> None:
self.extra = 1
reveal_type(SlottedChild.__slots__) # revealed: tuple[Literal["value"], Literal["other"]]
An ordinary slotted base also supplies storage for any matching dataclass field.
class SlottedBase:
__slots__ = ("value",)
@dataclass(slots=True)
class SlottedChild(SlottedBase):
value: int
other: int
reveal_type(SlottedChild.__slots__) # revealed: tuple[Literal["other"]]
Python 3.10 includes inherited fields in generated dataclass slots. ty does not currently model this version-specific runtime behavior and instead uses the Python 3.11-and-later behavior.
[environment]
python-version = "3.10"
from dataclasses import dataclass
@dataclass(slots=True)
class Parent:
value: int
@dataclass(slots=True)
class Child(Parent):
other: int
reveal_type(Child.__slots__) # revealed: tuple[Literal["other"]]
A dataclass-like decorator can also generate slots. The resulting class has the same restricted instance layout as an ordinary slotted dataclass.
from typing import Callable, TypeVar
from typing_extensions import dataclass_transform
T = TypeVar("T", bound=type)
@dataclass_transform()
def model(*, slots: bool = False) -> Callable[[T], T]:
raise NotImplementedError
@model(slots=True)
class SlottedModel:
value: int
def initialize(self) -> None:
self.other = 1 # error: [unresolved-attribute]
A slotted subclass of a built-in type without an instance dictionary cannot create extra attributes.
class SlottedString(str):
__slots__ = ("value",)
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
The structural mapping bases used to describe dict in typeshed do not add an instance dictionary.
class SlottedDictionary(dict[str, int]):
__slots__ = ()
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
staticmethod instances have instance dictionaries, so a slotted subclass can still create
additional attributes.
from typing import Any
class SlottedStaticMethod(staticmethod[..., Any]):
__slots__ = ("value",)
def __init__(self) -> None:
super().__init__(lambda: 1)
self.extra = 1
classmethod instances also have instance dictionaries.
class SlottedClassMethod(classmethod[Any, ..., Any]):
__slots__ = ("value",)
def __init__(self) -> None:
super().__init__(lambda cls: 1)
self.extra = 1
A standard-library class with slots declared in typeshed does not supply an instance dictionary. A slotted subclass inherits that restricted layout.
from pathlib import Path
class SlottedPath(Path):
__slots__ = ()
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
An ordinary standard-library class without a slot declaration supplies an instance dictionary even when its subclass declares slots.
from collections import Counter
class SlottedCounter(Counter[str]):
__slots__ = ()
def initialize(self) -> None:
self.extra = 1
Some classes implemented by the interpreter have restricted instance layouts that are not expressed
through __slots__ in typeshed.
from types import GenericAlias
class SlottedAlias(GenericAlias):
__slots__ = ()
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
A data descriptor can accept assignments even when its owning instance has no instance dictionary.
from typing import Any
class Descriptor:
def __set__(self, instance: object, value: int) -> None: ...
class SlottedDescriptor:
__slots__ = ()
value = Descriptor()
SlottedDescriptor().value = 1
SlottedDescriptor().value = "wrong" # error: [invalid-assignment]
Annotating a descriptor as Any does not hide the setter defined by the actual descriptor.
class AnnotatedDescriptor:
__slots__ = ()
value: Any = Descriptor()
AnnotatedDescriptor().value = 1
A custom __setattr__ method can decide how assignments are handled even when its instances have no
instance dictionaries.
class CustomSetter:
__slots__ = ()
shared = 1
def __setattr__(self, name: str, value: int) -> None: ...
CustomSetter().shared = 1
Typeshed declares __dict__ on object. As an intentional limitation, the attribute therefore
remains available through ordinary attribute lookup even when accessing it would raise an
AttributeError at runtime.
class Slotted:
__slots__ = ("value",)
reveal_type(Slotted().__dict__) # revealed: dict[str, Any]
reveal_type(Slotted.__dict__) # revealed: dict[str, Any]
An unslotted subclass can introduce an instance dictionary, so methods on a slotted base may access the dictionary after checking whether it exists.
from typing import Any
class SlottedBase:
__slots__ = ()
def attributes(self) -> dict[str, Any]:
if hasattr(self, "__dict__"):
return self.__dict__
return {}
class OrdinaryChild(SlottedBase):
pass
reveal_type(OrdinaryChild().__dict__) # revealed: dict[str, Any]
A slotted instance does not expose __weakref__ unless the slot is explicitly declared.
class Slotted:
__slots__ = ("value",)
Slotted().__weakref__ # error: [unresolved-attribute]
An explicit __weakref__ slot permits reads on the class and its instances. The typeshed descriptor
returns Any for both forms of access.
class WithWeakReference:
__slots__ = ("value", "__weakref__")
reveal_type(WithWeakReference.__weakref__) # revealed: Any
reveal_type(WithWeakReference().__weakref__) # revealed: Any
The typeshed descriptor permits writing and deleting, so the runtime restriction on weak-reference storage is not modeled.
# Both operations fail at runtime but are not currently rejected.
WithWeakReference().__weakref__ = None
del WithWeakReference().__weakref__
__dict__ does not provide instance storageA slotted class can expose a property named __dict__ without acquiring ordinary instance
dictionary storage.
class VirtualDictionary:
__slots__ = ()
@property
def __dict__(self) -> dict[str, int]:
return {"virtual": 1}
def initialize(self) -> None:
self.extra = 1 # error: [unresolved-attribute]
reveal_type(VirtualDictionary().__dict__) # revealed: dict[str, int]
Ordinary classes provide weak-reference storage at runtime, but their implicit __weakref__
attributes are not currently modeled. The same limitation applies to slotted subclasses.
[environment]
python-version = "3.11"
class OrdinaryBase:
pass
class SlottedChild(OrdinaryBase):
__slots__ = ("value",)
OrdinaryBase().__weakref__ # error: [unresolved-attribute]
SlottedChild().__weakref__ # error: [unresolved-attribute]
Without modeling that inherited storage, a slotted dataclass also includes a requested weak-reference slot even though the ordinary base already provides it at runtime.
from dataclasses import dataclass
@dataclass(slots=True, weakref_slot=True)
class SlottedDataclass(OrdinaryBase):
value: int
reveal_type(SlottedDataclass.__slots__) # revealed: tuple[Literal["value"], Literal["__weakref__"]]
A bare annotation does not require an instance slot because a subclass may supply the storage. The annotation does not make the attribute writable without a slot.
class Slotted:
__slots__ = ("value",)
value: int
missing: int
Slotted().missing = 1 # error: [missing-slot]
A subclass can provide the missing slot and use the inherited annotation.
class Child(Slotted):
__slots__ = ("missing",)
reveal_type(Child().missing) # revealed: int
Child().missing = 1
Assigning to a slot name in the class body prevents Python from creating the class.
class Conflicting:
__slots__ = ("value",)
value = 1 # error: [invalid-assignment]
A method with the same name also occupies the final class namespace and conflicts with the slot.
class ConflictingMethod:
__slots__ = ("value",)
def value(self) -> None: # error: [invalid-assignment]
pass
A temporary class variable that is deleted before the class is created does not conflict.
class DeletedDefault:
__slots__ = ("value",)
value = 1
del value
Class assignments inside TYPE_CHECKING blocks do not execute and therefore cannot conflict with
runtime slot descriptors. Pydantic uses this pattern for slotted attributes.
from typing import TYPE_CHECKING, ClassVar
class TypeCheckingOnly:
__slots__ = ("value",)
if TYPE_CHECKING:
value: ClassVar[int] = 1