Skip to content

Metaclasses & Descriptors

There is no TypeScript equivalent — and that is the point

Section titled “There is no TypeScript equivalent — and that is the point”

Descriptors and metaclasses are two of Python’s most powerful runtime features. TypeScript has decorators (stage-3, experimental), but they are much more limited. Python’s descriptor protocol and metaclass system operate at a lower level: they control how attribute access and class creation work at runtime. Understanding them explains how @property, @dataclass, and ORMs like SQLAlchemy actually work under the hood.

A descriptor is any object that defines __get__, __set__, or __delete__. When a descriptor instance is assigned as a class attribute, Python calls these methods instead of doing a plain dictionary lookup every time you access the attribute on an instance.

TypeScript
// TypeScript — closest analogy: getter/setter
class Temperature {
private _celsius: number;
constructor(celsius: number) {
this._celsius = celsius;
}
get fahrenheit(): number {
return this._celsius * 9/5 + 32;
}
set fahrenheit(value: number) {
this._celsius = (value - 32) * 5/9;
}
}
const t = new Temperature(100);
console.log(t.fahrenheit); // 212
t.fahrenheit = 32;
console.log(t.fahrenheit); // 32
Python
# Python — descriptor protocol (reusable across classes)
class Validated:
"""Descriptor: ensures field is numeric."""
def __set_name__(self, owner, name):
self._name = name # called at class creation
def __get__(self, obj, objtype=None):
if obj is None:
return self # class-level access
return getattr(obj, f"_{self._name}", None)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(
f"{self._name} must be numeric, "
f"got {type(value).__name__}"
)
setattr(obj, f"_{self._name}", value)
class Product:
price = Validated() # descriptor instance as class attr
quantity = Validated()
def __init__(self, price: float, quantity: int) -> None:
self.price = price
self.quantity = quantity
def total(self) -> float:
return self.price * self.quantity
item = Product(9.99, 3)
print(item.total()) # 29.97
item.price = "free" # TypeError: price must be numeric

The key insight: the Validated descriptor can be reused on any number of classes and any number of attributes. TypeScript getters/setters are per-class and per-property — you cannot share one definition.

__set_name__ is called by Python at class creation time (when the class body is executed), giving the descriptor the name of the attribute it was assigned to. This is why you do not need to write price = Validated("price") — Python injects the name automatically.

class Validated:
def __set_name__(self, owner, name):
self._name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, f"_{self._name}", None)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(
f"{self._name} must be numeric, got {type(value).__name__}"
)
setattr(obj, f"_{self._name}", value)
class Product:
price = Validated()
quantity = Validated()
def __init__(self, price: float, quantity: int) -> None:
self.price = price
self.quantity = quantity
def total(self) -> float:
return self.price * self.quantity
item = Product(9.99, 3)
print(f"Total: {item.total()}")
item.price = 19.99
print(f"Updated total: {item.total()}")
try:
item.price = "free"
except TypeError as e:
print(f"Caught: {e}")
What triggers a descriptor's `__get__` method?
What does `__set_name__` receive as arguments?
What is the default metaclass for all Python classes?
In a descriptor's `__get__`, when `obj is None`, what should you return?