2025, Nov 22 01:00
How to Type Dynamic Attributes in Pyright: Asymmetric Getter/Setter, the Any union trick, and TYPE_CHECKING
Learn how to type dynamic attributes with Pyright in VS Code: model asymmetric getter/setter, use the Any union, or add TYPE_CHECKING/.pyi stubs safely.
Static typing tools like Pyright in VS Code are great until dynamic patterns collide with strict inferences. A common case is an asymmetric attribute: you want to assign any Iterable, but whenever you read the attribute, it must behave as a specific list subclass with extra methods. With explicit @property this is easy to express; with a dynamic layer powered by __getattr__/__setattr__ it gets tricky to communicate intent to the type checker without reintroducing boilerplate.
Asymmetric attribute in a nutshell
Here’s the intended behavior with a straightforward property: the setter accepts any Iterable and wraps it as a specialized list subclass; the getter always returns that subclass.
from typing import Iterable
class EventList(list):
"""list with events for change, insert, remove, ..."""
def mark(self) -> str:
...
class Panel:
@property
def items(self) -> EventList:
return self._items
@items.setter
def items(self, data: Iterable):
self._items = EventList(data)
pane = Panel()
With this setup, Pyright understands two facts at once. First, you can assign any Iterable to the attribute. Second, reading the attribute returns an EventList, so calling EventList-specific methods is fine. The problem appears when the property is not explicitly defined because a dynamic layer provides attribute access automatically.
Why the dynamic version confuses the type checker
When __getattr__ and __setattr__ implement the behavior at runtime, the attribute does not exist in the class body. You still want a declaration to document the attribute’s type. If you annotate it as EventList, Pyright rejects assignments of plain lists. If you annotate it as Iterable or list, Pyright blocks calls to methods that only exist on EventList. If you try list | EventList, the checker treats the attribute as possibly either, so EventList-only methods are still not safe.
A pragmatic fix: the Any union trick
To reconcile “accept any assignment” with “read as EventList”, you can declare the attribute as EventList | Any. This keeps assignment permissive while allowing consumers to call EventList-specific APIs without unions getting in the way.
from typing import Any
class EventList(list):
"""list with events for change, insert, remove, ..."""
def mark(self) -> str:
...
class Panel:
items: EventList | Any
Panel().items = [2, 3] # OK: accepts a plain list on assignment
text: str = Panel().items.mark() # OK: attribute is usable as EventList
# Be aware of the trade-off below:
Panel().items = "no error" # also accepted
This approach deliberately trades assignment strictness for ergonomic reads. The attribute remains usable as the specialized type when you access it, but assignments are effectively unchecked.
Boilerplate-free but precise: TYPE_CHECKING or .pyi
If you prefer correctness over permissiveness without reintroducing runtime code, declare the property only for the type checker. This has no runtime effect yet preserves the exact asymmetric contract. A .pyi stub works the same way for projects that split typings from implementation.
from typing import Iterable, TYPE_CHECKING
class EventList(list):
"""list with events for change, insert, remove, ..."""
class Panel:
if TYPE_CHECKING:
@property
def items(self) -> EventList: ...
@items.setter
def items(self, seq: Iterable): ...
This communicates to Pyright that the attribute exists and behaves asymmetrically, without touching your dynamic attribute machinery.
Why this matters
GUI generators and similar frameworks rely on dynamic bindings to eliminate repetitive glue code. As projects adopt Pyright or similar tools to remove type errors, these dynamic patterns need a way to declare intent cleanly. The Any union trick gives you a one-line declaration that keeps code terse while making the attribute usable as the specialized type. The TYPE_CHECKING or .pyi route ensures fully accurate typing with zero runtime overhead at the cost of a small declaration block.
Takeaways
If the priority is minimal boilerplate with acceptable flexibility, declare the attribute as EventList | Any and be mindful that any assignment will pass through. If stricter guarantees are important, add a TYPE_CHECKING-only property (or a .pyi stub) to express the asymmetric getter/setter contract precisely. Both options keep dynamic attribute plumbing intact while restoring helpful type feedback in VS Code with Pyright.