Building Reusable Descriptors for Type Validation in Python
Writing the same validation logic across dozens of class attributes is repetitive and error-prone. Descriptors solve this by allowing you to define validation once and reuse it across many attributes and classes. This is how Pydantic, SQLAlchemy, and Django ORM achieve their declarative, type-safe APIs: a single descriptor class enforces rules on every instance, keeping validation centralized and maintainable. Building custom descriptors for type checking and value constraints is the foundation of professional data-modeling libraries and a key skill for writing frameworks.
The Type Validator Descriptor Pattern
Let's build a reusable descriptor that validates type and applies optional constraints:
class TypedProperty:
"""A descriptor that enforces type and optional validation."""
def __init__(self, name, type_, default=None, validator=None):
self.name = name
self.type_ = type_
self.default = default
self.validator = validator # Optional: callable(value) -> bool or raises
self.private_name = f'_{name}'
def __set_name__(self, owner, name):
# Use the class attribute name as the property name
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.private_name, self.default)
def __set__(self, obj, value):
# Type check
if not isinstance(value, self.type_):
raise TypeError(
f'{self.name} must be {self.type_.__name__}, '
f'not {type(value).__name__}'
)
# Custom validation
if self.validator and not self.validator(value):
raise ValueError(f'{self.name}={value} failed validation')
obj.__dict__[self.private_name] = value
class Person:
name = TypedProperty('name', str, default='Unknown')
age = TypedProperty('age', int, validator=lambda x: 0 < x < 150)
email = TypedProperty('email', str, validator=lambda x: '@' in x)
def __init__(self, name, age, email):
self.name = name
self.age = age
self.email = email
# Usage
person = Person("Alice", 30, "[email protected]")
print(person.name, person.age) # Output: Alice 30
# Type error
try:
person.name = 123
except TypeError as e:
print(f'Error: {e}') # Output: Error: name must be str, not int
# Validation error
try:
person.age = 200
except ValueError as e:
print(f'Error: {e}') # Output: Error: age=200 failed validation
This descriptor eliminates boilerplate setter methods and centralizes validation logic. The validator parameter accepts any callable returning True/False, making it flexible for custom business rules.
Union Types and Flexible Validation
For more complex scenarios, support multiple types and provide detailed error messages:
from typing import Union
from numbers import Number
class NumericProperty:
"""A descriptor for numeric types with range checks."""
def __init__(self, min_value=None, max_value=None, allow_float=True):
self.min_value = min_value
self.max_value = max_value
self.allow_float = allow_float
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
# Accept int or float (if allowed)
valid_types = (int, float) if self.allow_float else (int,)
if not isinstance(value, valid_types):
raise TypeError(
f'{self.name} must be numeric, got {type(value).__name__}'
)
# Range check
if self.min_value is not None and value < self.min_value:
raise ValueError(
f'{self.name} must be >= {self.min_value}, got {value}'
)
if self.max_value is not None and value > self.max_value:
raise ValueError(
f'{self.name} must be <= {self.max_value}, got {value}'
)
obj.__dict__[f'_{self.name}'] = value
class Temperature:
celsius = NumericProperty(min_value=-273.15, max_value=1000, allow_float=True)
def __init__(self, celsius):
self.celsius = celsius
def to_fahrenheit(self):
return self.celsius * 9/5 + 32
temp = Temperature(25)
print(f"{temp.celsius}°C = {temp.to_fahrenheit():.1f}°F") # Output: 25°C = 77.0°F
# Out of range
try:
temp.celsius = -300
except ValueError as e:
print(f'Error: {e}')
By separating numeric validation into its own descriptor, you create reusable components for different numeric fields across your codebase.
Descriptor Factory for DRY Validation
Create factories to reduce boilerplate when defining multiple similar descriptors:
def string_property(min_len=0, max_len=None, pattern=None):
"""Factory function returning a string-validating descriptor."""
import re
class StringProperty:
def __init__(self):
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
if not isinstance(value, str):
raise TypeError(f'{self.name} must be str, got {type(value).__name__}')
if len(value) < min_len:
raise ValueError(f'{self.name} length >= {min_len}, got {len(value)}')
if max_len and len(value) > max_len:
raise ValueError(f'{self.name} length <= {max_len}, got {len(value)}')
if pattern and not re.match(pattern, value):
raise ValueError(f'{self.name} does not match pattern {pattern}')
obj.__dict__[f'_{self.name}'] = value
return StringProperty()
class Product:
sku = string_property(min_len=3, max_len=20, pattern=r'^[A-Z0-9\-]+$')
name = string_property(min_len=1, max_len=200)
description = string_property(max_len=5000)
def __init__(self, sku, name, description):
self.sku = sku
self.name = name
self.description = description
product = Product("ABC-123", "Widget", "A useful widget")
print(product.sku) # Output: ABC-123
# Validation error: lowercase not allowed in pattern
try:
product.sku = "abc-123"
except ValueError as e:
print(f'Error: {e}')
Factories reduce repetition when similar validation rules apply across many attributes. This pattern is used in Pydantic's field definitions and Django's model field system.
Comparison: Manual Setters vs. Descriptors vs. Dataclass
| Approach | Lines of Code | Reusability | Type Hints | Modern |
|---|---|---|---|---|
Manual @property setters | High (5-10 per field) | None | Manual | Traditional |
| Descriptors | Medium (1-2 per field) | High (many classes) | Via type_ param | Professional |
@dataclass with validators | Low (1-2 per field) | Medium (inheritance) | Native | Python 3.7+ |
Key Takeaways
- Descriptors centralize validation logic, eliminating repetitive
@propertysetter boilerplate across multiple attributes. - Use
__set_name__to auto-discover the attribute name, reducing descriptor initialization code. - Descriptor factories (functions returning descriptor instances) enable DRY patterns for similar validations.
- Descriptors are the foundation of professional ORMs and data-validation libraries like Pydantic and SQLAlchemy.
Frequently Asked Questions
How do descriptors interact with type hints and IDE autocompletion?
Descriptors don't directly support type hints in the class definition. To help IDEs, annotate the class attribute with a type comment: name: str = TypedProperty('name', str). Modern tools like Pydantic use a metaclass (covered in later articles) to bridge descriptors and type hints.
Can I use descriptors in a dataclass?
Yes. If you define class attributes that are descriptors, the dataclass decorator respects them. However, dataclasses with validators are easier to read; use descriptors when you need high reusability across unrelated classes.
What happens if I access a descriptor attribute that was never set?
Your __get__ method is called. If you return a default or raise AttributeError, that's the result. Using __dict__.get(...) with a sensible default (as shown above) is idiomatic.
Do descriptors work with __slots__?
Yes. Since descriptors are class attributes, they work regardless of whether instances use __slots__ or a full __dict__. However, if you use __slots__, you cannot store the value in __dict__, so you must use a WeakKeyDictionary or an external storage mechanism.
Further Reading
- Python Descriptor Documentation — official guide with validation examples.
- Pydantic Field Validators — how Pydantic combines descriptors and type hints for validation.
- SQLAlchemy Column Definitions — see descriptors in production ORM code.
- Data Validation Patterns in Python — Real Python guide to validation and type safety.