Tagged unions¶
dx.TaggedUnion is the discriminated-union type. Each variant is a
real subclass and carries a Literal discriminator, so dispatch on
model_validate is by string tag.
import didactic.api as dx
from typing import Literal
class Shape(dx.TaggedUnion, discriminator="kind"):
kind: str
class Circle(Shape):
kind: Literal["circle"] = "circle"
radius: float
class Square(Shape):
kind: Literal["square"] = "square"
side: float
The base class declares the discriminator field name. Each variant narrows the discriminator to a single literal value.
Construction¶
Each variant is a real class, so direct construction works:
Validation¶
Shape.model_validate dispatches on the discriminator:
A payload whose discriminator does not match any registered variant
raises a ValidationError. The same holds one level up: when a union
sits behind a field of some other model, a payload naming an
unregistered value fails that model's validation with an entry located
at the field, rather than escaping as a bare KeyError from the
decoder.
Listing variants¶
Every TaggedUnion subclass exposes its variants:
This is the surface code-generation tools and schema-diff tools read when emitting the union.
Recursive and mutually recursive variants¶
A variant may carry the union root as a field type:
from typing import Literal
class Node(dx.TaggedUnion, discriminator="kind"):
pass
class Lit(Node):
kind: Literal["lit"]
value: int
class BinOp(Node):
kind: Literal["binop"]
op: str
left: Node # the union itself, not a specific variant
right: Node
class ListLit(Node): # registered after BinOp
kind: Literal["list_lit"]
elements: tuple[int, ...] = ()
The variant registry is consulted live at encode and decode time,
so a variant declared later (here ListLit) is a legal child of an
earlier variant's union-typed field. Mutually recursive AST shapes
work: any variant can sit inside any other variant's union-typed
field, regardless of declaration order.
Construction accepts both fully-built variant instances and dict payloads carrying the discriminator:
BinOp(
kind="binop",
op="+",
left={"kind": "lit", "value": 1}, # dict dispatches via discriminator
right=Lit(kind="lit", value=2),
)
model_dump_json / model_validate_json round-trip recursive
unions: nested variants are written as their natural JSON shape
(the discriminator key is the constructor tag) and reconstructed by
dispatching each child dict through the live variant registry.
Declaring the root before its variants¶
A root is a legal field type before any variant is registered:
class ParserSpec(dx.TaggedUnion, discriminator="kind"):
pass
class RunSpec(dx.Model):
parser: ParserSpec # no variant exists yet
class ChartParser(ParserSpec): # registered afterwards
kind: Literal["chart"] = "chart"
beam: int = 8
This is what a layered application needs. The root sits in a low layer
and its variants in higher layers that the low layer must not import,
so the module declaring RunSpec cannot make a variant exist before
its own class body runs. Field classification therefore never consults
the registry; the optional, tuple and dict spellings
(ParserSpec | None, tuple[ParserSpec, ...], dict[str, ParserSpec])
behave the same way.
The Theory follows the registry as well. The union's sum sort is built
when RunSpec.__theory__ is first read rather than when parser was
classified, so it lists one constructor per variant registered by that
point, and a root with none yet lists no constructors. __theory__ is
cached on first read, so read it once the variants are imported.
The sum sort's closure is Open, and its arms are named by a
constructors key. Closed would say that those constructors are the
only ways to build a value of the sort, which a union-typed field
contradicts: parser is an operation from RunSpec to ParserSpec,
so it builds one too. panproto checks exactly that, and a Closed
sum sort alongside the accessor fails the check.
Union of two TaggedUnion roots¶
A field annotated A | B where both A and B are TaggedUnion
roots dispatches via the union of their variant registries:
class A(dx.TaggedUnion, discriminator="kind"): ...
class A1(A):
kind: Literal["a1"] = "a1"
name: str
class B(dx.TaggedUnion, discriminator="kind"): ...
class B1(B):
kind: Literal["b1"] = "b1"
name: str
class Combo(dx.Model):
items: tuple[A | B, ...]
The two roots must share the same discriminator field name; their discriminator-value sets must be disjoint. The first requirement is checked at class-creation time; the second is checked the first time an actual encode or decode hits a colliding value (so a model whose union-typed field is never encoded with a colliding variant remains usable).
Limitations¶
Discriminator values must be string literals. Non-string discriminators
(integer kinds, enum members) are not currently supported; if you
need that, model the discriminator field as a Literal["a", "b"]
typed str.