Forward references, recursion, and generics¶
Talea resolves recursive and generic declarations into the same canonical
schemas used by ordinary Specs. A concrete specialization compiles concrete
validators, input boundaries, and serializers; construction never looks up a
TypeVar or interprets a recursive schema.
Forward references¶
Python 3.14 normally defers annotations, so a later declaration can be named directly or with an explicit string:
from talea import Spec
class Employee(Spec):
manager: "Manager | None"
class Manager(Spec):
name: str
Talea finalizes a declaration immediately when all names are available. If a
name is genuinely pending, Talea retains the declaration identity and resolves
the reachable graph on its first concrete operation. Construction,
from_mapping, from_json, to_dict, and to_json all cross that same
finalization boundary. There is no public rebuild or model-registry API.
An unresolved name fails with AnnotationResolutionError. The message names
the missing symbol, owning Spec, and field. No validator or serializer is
published before the declaration graph is resolved, so a stale partial artifact
cannot survive later resolution.
Explicit annotation strings are accepted only when their syntax is structural:
names, attributes, subscriptions, tuples, and | unions. The exact
Discriminator("field") metadata call is also accepted for recursive tagged
Spec graphs. Other function calls and executable expressions are rejected
before Python evaluates them. Prefer
normal Python 3.14 annotations and use string references only where a pending
name requires one.
Recursive Specs¶
A Spec may refer to itself through any supported container or union:
class Node(Spec):
value: int
children: list[Node]
root = Node(
value=1,
children=[Node(value=2, children=[])],
)
The canonical field schema contains a nominal reference to Node; it does not
copy Node's fields into itself. Validators, Mapping/JSON input, and
serialization compile a deferred back edge to that same declaration identity.
This keeps recursive schemas finite and gives every execution domain one
structural truth.
Recursion also works through dictionaries, fixed and variadic tuples,
Annotated, constraints, aliases, and unions when the underlying annotation is
otherwise supported.
Mutually recursive Specs¶
Mutual recursion uses the same graph finalization:
class Folder(Spec):
parent: "Folder | None"
files: list["File"]
class File(Spec):
folder: Folder
name: str
Names declared in a function-local scope are supported without a process-wide registry. Talea retains that defining local scope only while an unresolved declaration needs it and releases it after finalization.
Mapping and JSON input¶
Recursive external data is converted at every declared Spec edge:
data = {
"value": 1,
"children": [{"value": 2, "children": []}],
}
node = Node.from_mapping(data)
same_values = Node.from_json(node.to_json())
assert same_values.to_dict() == data
Failures preserve the complete path. An invalid grandchild value, for example,
can report ("children", 0, "children", 0, "value").
Serialization and cycle policy¶
to_dict() and to_json() traverse acyclic recursive values and retain their
existing alias, include/exclude, custom serializer, and JSON representation
contracts.
Talea deliberately distinguishes recursive declarations from cyclic runtime objects. Mutable containers can be used after construction to create an object cycle. Current-state validation handles a repeated object identity safely, but Mapping/JSON input and serialization reject cycles:
- cyclic Mapping input raises
ValidationErrorwith code"cycle"and the exact back-edge location; - cyclic
to_dict()orto_json()raisesSerializationErrorwith the exact back-edge location; - successful acyclic paths pay identity tracking only for recursive declarations.
External Mapping and JSON input also consume the selected ResourcePolicy
depth and node budgets. Strict trusted construction and output remain subject
to normal Python resource limits; Talea does not silently truncate a graph.
Trust¶
An immutable recursive graph, such as an optional parent link without mutable containers, can be permanently trusted. A recursive list, dictionary, or set keeps the declaration non-permanently-trusted because the retained container can change. Recursive current-state validation uses operation-local identity tracking so a runtime cycle cannot recurse without bound.
Generic Specs¶
Talea uses Python 3.14 type-parameter syntax directly:
Box[int] and Box[str] are distinct concrete Spec classes. Each owns a
canonical concrete schema and specialized compiled execution. Box[int]
therefore keeps strict integer semantics and rejects "1"; there is no runtime
TypeVar dispatch.
Repeated Box[int] subscription returns the same class identity while it is in
use. The generic origin owns a weak specialization cache, so unused
specialization classes are collectible rather than permanently retained by a
global registry.
An unspecialized generic is a declaration template, not a runtime model:
Nested and recursive generics¶
Generic Specs compose through containers and other generic Specs:
class Page[T](Spec):
items: list[T]
class Response[T](Spec):
page: Page[T]
response = Response[int].from_mapping({"page": {"items": [1, 2]}})
assert type(response.page) is Page[int]
A recursive generic substitutes its back edge with the same concrete specialization:
class Tree[T](Spec):
value: T
children: list[Tree[T]]
tree = Tree[str].from_mapping(
{"value": "root", "children": [{"value": "leaf", "children": []}]}
)
Inheritance and partial binding¶
Generic inheritance materializes the concrete base schema before composing the child's flat effective declaration:
class Base[T](Spec):
value: T
class Child[T](Base[T]):
label: str
class IntegerChild(Base[int]):
label: str
Child[str] validates a string value; IntegerChild is already concrete.
Inheriting an unspecialized generic base into a non-generic child is rejected.
Partially bound forms inside another generic declaration are retained and
completed when the outer type is specialized.
Bounds, constraints, and type-parameter defaults¶
Bounds and constraints are checked when the specialization is created:
class Entity(Spec):
identifier: int
class Ref[T: Entity](Spec):
value: T
class Choice[T: (int, str)](Spec):
value: T
class Defaulted[T = int](Spec):
value: T
An incompatible type argument raises TypeError before instances or artifacts
are used. Since Python subscription syntax requires something between the
brackets, Defaulted[()] requests all declared type-parameter defaults and is
identical to Defaulted[int].
| Parameter form | Runtime support |
|---|---|
TypeVar / class Model[T] |
Yes |
Bound T: Base |
Yes |
Constrained T: (A, B) |
Yes |
Default T = Default |
Yes |
| Nested or partially bound TypeVars | Yes |
ParamSpec / **P |
Rejected |
TypeVarTuple / *Ts |
Rejected |
Only annotations already supported by Talea may be used as concrete type arguments. Specialization does not broaden the canonical schema language.
Defaults, factories, hooks, and serializers¶
Static defaults are validated against the concrete specialization. Factories
run per instance and their results are checked against the substituted schema.
transform, check, and serialize callbacks are inherited by each concrete
specialization and receive the specialized field values at runtime:
from talea import check, field, serialize
class Produced[T](Spec):
value: T = field(default_factory=lambda: 1)
@check("value")
def positive(value: T) -> None:
if value <= 0:
raise ValueError("positive value required")
@serialize("value")
def output(value: T) -> str:
return str(value)
Static type checkers see the PEP 695 declaration directly: Box[int].value is
int, nested generic fields remain parameterized, and recursive fields retain
their concrete recursive type.
Recursive aliases and TypedDict graphs¶
Named PEP 695 aliases and TypedDict declarations participate in the same finite reference graph as Specs:
from typing import NotRequired, TypedDict
from talea import Contract
type JSONValue = (
str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]
)
class NodePayload(TypedDict):
value: int
children: list[NodePayload]
parent: NotRequired[NodePayload]
json_values: Contract[JSONValue] = Contract(JSONValue)
nodes: Contract[NodePayload] = Contract(NodePayload)
Recursive aliases can be self-recursive, mutually recursive, or mixed with
TypedDict declarations. Identity comes from the declaration object and its
concrete arguments, not only from a display name. Required, NotRequired,
inherited, ReadOnly, metadata, and unknown-key behavior remain the same as for
non-recursive TypedDict.
Concrete generic recursive aliases and TypedDicts are supported:
type Tree[T] = T | list[Tree[T]]
class BoxNode[T](TypedDict):
value: T
children: list[BoxNode[T]]
integer_tree: Contract[Tree[int]] = Contract(Tree[int])
integer_nodes: Contract[BoxNode[int]] = Contract(BoxNode[int])
Open generic aliases and TypedDicts remain non-executable. Specializations are weakly retained where class identity caching applies; Contracts retain the concrete graph they own and no process-global alias registry is created.
Tagged unions can appear at recursive edges, including TypedDict AST branches.
A known discriminator selects one compiled branch at each level. JSON Schema
and OpenAPI use finite definitions/components and $ref back-edges from the
same named identity graph.
Python must be able to resolve deferred names through the declaration's normal
Python 3.14 namespace. Runtime cycles are rejected rather than reconstructed.
Introspection exposes finite NamedReferenceSchema values rather than embedding
the target graph recursively.
Copying and pickle¶
copy.copy() and copy.deepcopy() preserve the concrete class without
rerunning transforms, checks, or factories. Deep copy preserves repeated and
cyclic object identities through Python's memo protocol.
Acyclic instances of importable plain and concrete generic Specs support
trusted Python pickle reconstruction. Pickle has its normal code-execution
security model and must never be used for untrusted data. Function-local Spec
classes remain subject to Python's normal importability limitation, and cyclic
Spec pickle graphs are not a Talea persistence format. Use to_dict() or
to_json() for supported untrusted-data boundaries.
Performance model¶
Resolution and first specialization are cold class-level costs. Repeated specialization is a cache lookup, and concrete construction uses the same compiled shape as an equivalent non-generic Spec. Recursive construction, conversion, and serialization scale with the actual traversed data. Ordinary non-generic, non-recursive Specs retain their direct generated constructors and do not carry recursive input/output state or current-state validators.
Recursive tagged AST¶
A recursive abstract syntax tree combines several ideas that are easy to confuse in isolation: a PEP 695 recursive alias, TypedDict branch declarations, a protocol discriminator, container recursion, nested error locations, cycle rejection, and finite schema references. The executable example models literal, binary, and function-call expressions and round-trips a realistic JSON tree.
"""A recursive, tagged expression language with direct JSON dispatch."""
from typing import Annotated, Literal, TypedDict, cast
from talea import Contract, Discriminator, MaxLength, ValidationError
class SourceLocation(TypedDict):
line: int
column: int
class LiteralExpression(TypedDict):
type: Literal["literal"]
value: int | str | bool | None
location: SourceLocation
class BinaryExpression(TypedDict):
type: Literal["binary"]
operator: Literal["+", "-", "*", "/"]
left: Expression
right: Expression
location: SourceLocation
class FunctionCall(TypedDict):
type: Literal["call"]
function: Annotated[str, MaxLength(80)]
arguments: list[Expression]
location: SourceLocation
type Expression = Annotated[
LiteralExpression | BinaryExpression | FunctionCall,
Discriminator("type"),
]
expressions: Contract[Expression] = Contract(Expression)
source = """{
"type": "call",
"function": "round",
"arguments": [
{
"type": "binary",
"operator": "/",
"left": {"type": "literal", "value": 10, "location": {"line": 1, "column": 7}},
"right": {"type": "literal", "value": 3, "location": {"line": 1, "column": 12}},
"location": {"line": 1, "column": 7}
}
],
"location": {"line": 1, "column": 1}
}"""
expression = expressions.from_json(source)
assert expression["type"] == "call"
assert expression["arguments"][0]["type"] == "binary"
assert expression["arguments"][0]["right"]["type"] == "literal"
assert expressions.from_json(expressions.to_json(expression)) == expression
try:
expressions.from_json(source.replace('"value": 3', '"value": []'))
except ValidationError as error:
detail = error.errors()[0]
assert detail["code"] == "union"
assert detail["location"] == ["arguments", 0, "right", "value"]
else:
raise AssertionError("a list is not a supported literal value")
try:
expressions.from_json(source.replace('"type": "binary"', '"type": "unknown"'))
except ValidationError as error:
assert error.errors()[0]["code"] == "discriminator_unknown"
assert error.errors()[0]["location"] == ["arguments", 0, "type"]
else:
raise AssertionError("an unknown recursive node tag must fail")
schema = expressions.json_schema()
assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
definitions = cast(dict[str, object], schema["$defs"])
assert {"LiteralExpression", "BinaryExpression", "FunctionCall"} <= set(definitions)
openapi = expressions.openapi_schema()
components = cast(dict[str, object], openapi["components"])
schemas = cast(dict[str, object], components["schemas"])
root = cast(dict[str, object], schemas["Expression"])
assert cast(dict[str, object], root["discriminator"])["propertyName"] == "type"
# The declaration is a finite recursive type graph. A Python container that
# contains itself is a cyclic runtime value and is rejected on Python input or
# output rather than being mistaken for valid recursion.
type NestedIntegers = int | list[NestedIntegers]
nested: Contract[NestedIntegers] = Contract(NestedIntegers)
cyclic: list[object] = []
cyclic.append(cyclic)
try:
nested.from_python(cyclic)
except ValidationError as error:
assert error.errors()[0]["code"] == "cycle"
else:
raise AssertionError("cyclic runtime data must not recurse forever")
The type graph is recursive but finite: Expression points to branch
definitions, and binary/call branches point back to Expression. Each actual
JSON value is finite and acyclic. A Python list that contains itself is instead
a cyclic runtime value; it has no JSON tree representation and Talea reports a
cycle failure.
Tagged recursion is appropriate when each node carries a stable grammar tag. It makes invalid node kinds precise, dispatches directly, and gives OpenAPI a discriminator mapping. An ordinary recursive Spec is simpler when every node has one shape; a recursive alias is simpler when no named record behavior is needed. Keep AST evaluation, name resolution, permissions, and execution limits outside the structural contract unless they are value invariants.
For production input, combine the recursive contract with a ResourcePolicy.
Depth limits protect structural nesting, node limits bound compiled visits, and
tagged dispatch prevents unrelated branches from being attempted. See
Resource and security for the exact accounting model.