Generic Models¶
A dx.Model subclass declared with type parameters synthesises a
concrete subclass on subscript. Range[int](min=0, max=10) produces
an instance of Range[int], a real subclass cached per type-arg
tuple, with the parent's T-typed fields rewritten to use int.
Declaring a generic Model¶
PEP 695 syntax (recommended):
Legacy Generic[T] mixin syntax also works:
from typing import Generic, TypeVar
T = TypeVar("T", int, float)
class Range(dx.Model, Generic[T]):
min: T
max: T
Subscripting¶
Range[int] returns a real subclass of Range. Repeated subscripts
return the same class object:
The synthesised class participates in everything an explicit subclass
would: model_dump(), model_validate_json(), lenses, axioms,
codegen, Repository.add(...). Its __field_specs__ carries
concrete sorts (e.g. Int for int, not the deferred _TypeVar:T
placeholder).
Defaults and metadata propagate¶
Defaults on the generic carry through to the synthesised subclass:
class Range[T: int | float](dx.Model):
min: T = 0
max: T = 100
Range[int]()
# Range[int](min=0, max=100)
dx.field(...) metadata also propagates: default,
default_factory, description, alias, examples, deprecated,
nominal, usage_mode, extras, and converter all flow onto the
synthesised subclass's spec.
class Counter[T](dx.Model):
value: T = dx.field(default=0, description="a counter")
Counter[int].__field_specs__["value"].description
# 'a counter'
Substitution through nested shapes¶
The substitution walks through the common generic containers,
unions, and Annotated[...]:
from typing import Annotated
from annotated_types import Ge
class Items[T](dx.Model):
seq: tuple[T, ...] = ()
by_name: dict[str, T] = dx.field(default_factory=dict)
maybe: T | None = None
bounded: Annotated[T, Ge(0)] = 0
Each is rewritten correctly under subscript:
| declared | after Items[int] |
|---|---|
tuple[T, ...] |
tuple[int, ...] |
dict[str, T] |
dict[str, int] |
T \| None |
int \| None |
Annotated[T, Ge(0)] |
Annotated[int, Ge(0)] |
The Annotated[...] metadata is preserved verbatim, so the
annotated-types axioms (Ge, Le, MinLen, etc.) continue to
fire on the synthesised subclass.
Multiple type parameters¶
class Pair[K, V](dx.Model):
key: K
value: V
Pair[str, int](key="x", value=42)
# Pair[str, int](key='x', value=42)
Arity must match: Pair[int] raises TypeError.
Subclassing a parameterised generic¶
Range[int] is a real class, so IntTree subclasses it normally.
IntTree.__field_specs__ contains min, max, and label.
What stays unsubstituted¶
A bare dx.Model subclass passed as a type argument is not
auto-wrapped in Embed[T] or Ref[T]. If you want the generic to
hold an embedded sub-model, declare it explicitly:
class Container[T](dx.Model):
item: dx.Embed[T]
class Person(dx.Model):
name: str
Container[Person](item=Person(name="alice"))
Container[Person] substitutes Embed[T] to Embed[Person].
Constructing the unparameterised generic¶
Constructing the bare generic without subscripting raises:
Range(min=0, max=10)
# ValidationError: cannot encode a TypeVar-annotated field; the class
# is generic and must be parameterised before construction
The TypeVar guards on the original class stay in place; only the synthesised subclass has concrete sorts.
TypeVar constraints¶
TypeVar("T", int, float) is enforced statically by your type checker
but not at didactic runtime. Range[str] synthesises a class whose
min field has sort String; the TypeVar's static int | float
constraint is information for the type checker, not a runtime guard.
Cache lifetime¶
The cache lives on the generic class as __parameterised_cache__. A
synthesised subclass is held alive by the cache for as long as the
generic class itself is reachable; if the generic class is garbage
collected, the cache (and every entry) goes with it.