Skip to content

Field semantics

Every Spec field is keyword-only. An annotation alone declares a required field, including when the annotation accepts None:

from talea import Spec, field


class Required(Spec):
    value: int | None


class Defaulted(Spec):
    value: int | None = None


class Basket(Spec):
    items: list[int] = field(default_factory=list)

Required() fails because accepting None does not imply omission. Defaulted() receives None. Each Basket() receives a new list.

Static defaults

Talea validates static defaults when the class is declared. This is the earliest deterministic point at which both the resolved schema and the value exist, and it prevents an invalid declaration from manufacturing trusted instances later. Static defaults are reused without validation when omitted; explicit replacements are validated normally.

Lists, sets, dictionaries, and immutable containers containing them are rejected as static defaults. Talea does not copy defaults implicitly. Use field(default_factory=...) for mutable values so ownership and construction cost are explicit.

Default factories

field is a declaration function, not an instance wrapper. Its default_factory is called once when the corresponding argument is omitted, is not called for an explicit argument, and is never called merely to inspect a class declaration. The returned value passes through the same strict validation operations as an explicit value. One canonical validation emitter generates both standalone validators and the operations inlined into Spec constructors; construction does not call a Python validator function per field. A factory exception becomes a field-located ValidationError with code factory and the original exception retained as its cause. A returned value that fails validation keeps the actual structural or constraint code.

When a field declares an inbound transform, factory output runs that transformation before structural validation. Static defaults do not run transforms; they must already be valid developer-provided Python values. Both static defaults and factory results must satisfy declared field check callbacks. See Custom validation.

Immutability and validated trust

Spec field bindings are immutable after construction. Assignment and deletion raise AttributeError, including assignment to unknown attributes. Initialization uses Talea's generated constructor to validate all values before writing slots.

Shallow immutability is not misrepresented as permanent trust. A declaration whose schema contains a list, set, or dictionary is validated at construction but is canonically marked as requiring current-state revalidation at a new boundary, including when that mutable container is nested in a tuple, frozenset, or union. A declaration containing only transitively immutable value schemas is permanently trusted. Referenced Spec declarations propagate their own classification: an immutable nested Spec preserves permanent trust, while a Spec containing a mutable value makes the containing declaration ineligible. At each new nested validation boundary, permanently trusted references require only nominal compatibility. Non-permanently-trusted references validate their current canonical field state before they are accepted. This makes it impossible for an already-invalid mutable Spec to cross a new validation boundary solely because its Python type still matches.

Three policies were considered:

  • Immutable by default: one construction validation boundary, no assignment compiler or assignment-time cost, and a direct trust guarantee.
  • Mutable with compiled assignment validation: coherent, but introduces a second validation path and permanent assignment overhead for every Spec.
  • Configurable mutability: possible, but adds policy and generated variants before a concrete consumer requires them.

Immutable-by-default bindings plus canonical permanent-trust classification win because they preserve direct field-read performance and ordinary Python container semantics without making a false deep-immutability claim. Talea does not provide mutable field bindings or assignment validation.

Canonical ownership

The immutable ordered SpecSchema owns each field's name, resolved schema, required, static-default, or factory state, canonical declaration metadata, effective custom-hook order, and the derived permanent-trust classification. Generated constructors and standards projections consume that retained truth. Instances contain only their field values and never reconstruct lifecycle or metadata semantics from annotations. See Metadata and sensitive fields.

Current and legacy external names

Alias owns the external field-name contract. Its name is the current external name used for input and output. Its keyword-only legacy tuple lists finite historical names accepted only by Mapping and JSON input:

from typing import Annotated

from talea import Alias, Spec


class UserLookup(Spec):
    user_id: Annotated[int, Alias("userId", legacy=("id", "user_id"))]

This declaration can receive API v1 {"id": 7}, API v2 {"user_id": 7}, or current {"userId": 7} payloads. The Python constructor and attribute remain user_id. Because the current external name differs from the Python name, user_id is accepted externally here only because it is explicitly listed in legacy; adding legacy vocabulary does not silently broaden every alias to accept its Python spelling.

One payload may supply exactly one accepted name for a field. Supplying userId and id, or id and user_id, raises ValidationError with code alias_conflict at userId. Equal values still conflict: Talea does not compare values, infer precedence, or treat tuple order as newest-to-oldest priority. The error may expose conflicting_names, but it retains no conflicting values. The same value-free rule applies to Sensitive fields.

Validation is identical whichever accepted name supplied the value. A bad value under id receives the field's ordinary structural, constraint, or hook code at the current external location userId; there is no legacy-specific validator. Missing and unrelated keys remain missing and unexpected. Duplicate textual JSON keys remain the decoder-owned json_duplicate error, which is distinct from two different accepted names identifying one field.

Output is canonical and one-way:

lookup = UserLookup(user_id=7)
assert lookup.to_dict() == {"userId": 7}
assert lookup.to_json() == '{"userId":7}'

Legacy names are never emitted. JSON Schema and OpenAPI input mode publish each accepted spelling with a per-field Draft 2020-12 oneOf presence law, so any one spelling is accepted and conflicts are rejected. Output mode publishes the current property only. inspect_spec() exposes each field's external_name, immutable legacy_names, and complete ordered accepted_input_names. The order determines stable projection and inspection order, never runtime precedence. See JSON Schema and OpenAPI projection.

Talea owns accepted external-name truth, deterministic ambiguity rejection, validation, errors, and introspection. The application owns the decision to make a name legacy, warnings and telemetry, retirement timing, API-version policy, semantic value migration, and source-version negotiation. Talea does not infer migration chronology from tuple position and does not provide a migration registry or version manager.

A staged rollout keeps those responsibilities explicit:

  1. Phase A declares and emits id.
  2. Phase B changes the current name to userId and declares legacy=("id",). Clients may send either one; sending both is rejected, and server output immediately uses userId only.
  3. After application telemetry and policy say old clients are gone, a later application release removes id from the tuple. Talea does not warn, schedule, negotiate, or decide that retirement.

Current names and legacy names must be unique within one field. Across an effective ordinary Spec declaration, current/current, current/legacy, and legacy/legacy collisions fail during declaration so an input object cannot identify two fields with one name. This validation naturally includes ordinary inherited effective fields.

Migration truth follows the field through normal composition:

  • an inherited field keeps its current and legacy names; an annotation-only override keeps that inherited Alias, while a local Alias replaces it as one metadata item;
  • Alias("accountId") is therefore an explicit way to keep the current name while removing inherited legacy names;
  • derive_spec() include, exclude, partial, input, and output projections keep the canonical field object for every retained field; omitted fields leave no migration vocabulary behind;
  • a legacy spelling supplied to a partial marks the canonical Python field in present_fields, and apply_patch() applies that canonical field without weakening exact-source provenance;
  • create_spec(), concrete generic specialization, recursion, containers, and nested Specs consume the same retained field truth without rereading annotations.

Directional views select fields; they do not create a different field-name policy. If an output-derived Spec is later used as an ordinary Mapping or JSON input boundary, retained fields accept their legacy names. Its output still uses only current names.

Stdlib dataclass fields retain the same external_name, legacy_names, and accepted_input_names projection in DataclassField. Contract(Dataclass) uses it for Mapping and JSON construction, while the original dataclass constructor remains the sole lifecycle owner. Tagged Spec unions require every branch discriminator field to share one complete accepted-name vocabulary. Dispatch looks up that vocabulary once, rejects multiple spellings before branch execution, and still runs only the selected branch. TypedDict keys do not gain Alias migration semantics in this release.

These checks are compiled only for fields or discriminators with non-empty legacy tuples. Ordinary Spec, dataclass, and tagged-union generated paths keep their direct current-name lookup and contain no alias-conflict branch.

Practical account fields

from typing import Annotated

from talea import Alias, MinLength, Sensitive


class AccountCreate(Spec):
    display_name: Annotated[str, Alias("displayName"), MinLength(1)]
    recovery_email: str | None = None
    token: Annotated[str, Sensitive()]
    labels: list[str] = field(default_factory=list)

display_name is required, externally named displayName, and cannot be empty. recovery_email is optional in ordinary construction because it has a default, not merely because it accepts None. token is required and redacted from Talea-owned repr/errors. labels gets a per-instance mutable list.

At a JSON boundary, aliases are accepted and nested locations use external names. to_dict() and to_json() use aliases by default. Sensitive does not omit token from successful output; an outward response Spec should simply not declare request credentials.

Factory and declaration failures

def broken_factory() -> list[str]:
    raise RuntimeError("configuration unavailable")


class Batch(Spec):
    labels: list[str] = field(default_factory=broken_factory)

Constructing Batch() raises a field-located ValidationError with code factory and retains the trusted application cause. A factory returning the wrong type receives the normal structural code instead. Sensitive factories and callbacks discard unsafe causes where retaining them could leak the marked value.

An invalid static default fails while the class is declared. This is a startup or import defect, not request validation. Fix the declaration rather than catching it inside a handler.

Defaults in PATCH and schema

A partial derived Spec does not materialize source defaults or call factories for absent fields. Supplying a value equal to the source default still marks it present. This is essential for APIs where “reset to default” differs from “make no change.”

Static defaults project to JSON Schema only when they can be represented without running application code and do not contain Sensitive or serializer state. Factories are never invoked for schema generation because they do not declare one stable value.

Error, security, and performance guidance

Field validation follows declaration order and successful construction does not allocate rich error detail. Defaults reuse already-validated immutable values; factories and explicit inputs pay their direct transform/validation/check work. Mutable current state pays revalidation when it later crosses a boundary.

Do not put database lookups, network I/O, authorization, or changing business policy in default factories or field checks. They are trusted synchronous callbacks on object construction and are outside ResourcePolicy. Keep factories deterministic and local; keep external effects in the application operation.