Skip to content

pg.Object design & style

Conventions for writing pg.Object subclasses that interact cleanly with runtime validation and with static type-checkers (pyright/Pylance/mypy). The patterns here come out of repeated friction with PEP 681's dataclass_transform model — they're not arbitrary stylistic choices.

What pg.Object offers

  • Schema-driven, dataclass-like classes. Declare fields with type annotations; an __init__ is synthesized from them — no boilerplate.
  • Runtime field validation. Every field carries a pg.typing.ValueSpec derived from its annotation. Types, defaults, numeric bounds, regex, callable signatures, etc. are enforced at construction and at every assignment.
  • Static type checking via PEP 681. pg.Object is decorated with dataclass_transform, so pyright/Pylance/mypy infer the synthesized __init__ signature, field types, and field defaults without a custom plugin.
  • Symbolic operations out of the box. Value-based __eq__ / __hash__ (sym_eq / sym_hash), deep cloning (sym_clone, with override= as the dataclasses.replace() analog), JSON serialization (to_json / from_json), formatted repr (format), and — under topo=True — in-place mutation via sym_rebind.
  • Per-class behavioral knobs. Class-statement kwargs flip immutability, freezing, equality semantics, and serialization-registry membership (see Class-level behaviors).
  • Per-field flags. Skip __init__, exclude from repr / eq / hash, attach docstrings or metadata, hook a post-validation transform or a custom validator (see Field-level behaviors).
  • Lifecycle hooks. on_sym_ready runs after fields are validated and attached, once the object is concrete — the natural place to compute derived state. on_sym_bound is its completeness-agnostic sibling (fires even when partial); on_sym_change fires on sym_rebind for incremental update.
  • Model validators. on_sym_validate checks cross-field invariants: it fires with all fields committed — at construct (before any lifecycle hook) and on every write — and raising rejects the construct or rolls the write back before observers see it. on_sym_preinit (a classmethod) reshapes the raw init kwargs before resolution — accept legacy aliases, derive fields — returning the mapping to construct from.
  • Inheritable. Subclasses inherit fields, value specs, and per-class options; each level can overlay overrides.

The semantic spectrum: dataclass → symbolic object

pg.Object is not one fixed thing. The same machinery spans a spectrum of class semantics — from a plain mutable dataclass at one end to a fully symbolic, immutable, tree-aware value object at the other. You pick where you sit by setting class-statement kwargs (and override per-field where a single field needs to differ). There is no separate "dataclass" type to switch to; you turn knobs on the one you already have.

The top-level axis is topo: it selects symbolic-tree vs. flat dataclass semantics. Within it, the remaining knobs layer additively. From the irreducible base upward:

Layer Knob(s) What turning it on adds
Storage + init (always present) Synthesized keyword-only __init__ from annotations, typed fields, per-instance copying of mutable defaults. This is the dataclass you always get.
Symbolic tree (top axis) topo topo=False (the default) is a flat, reference-semantics object: no tree, no single-position rule, raw members. topo=True (opt-in, inherited by subclasses) makes instances nodes in a symbolic object tree — topo_path / topo_parent / topo_root, a single tree position per node, contextual resolution, change notification — and it is what an unauthored container spec follows when deciding storage (see Container storage).
Validation validate Type-checking, coercion, and default-filling through each field's ValueSpec.apply. Meaningful in both topo modes.
Container storage the ANNOTATION Whether a raw dict / list is stored as-is or wrapped into pg.Dict / pg.List so it joins the tree. Declared by the field's type: pg.Dict[str, V] / pg.List[E] is symbolic on both axes, builtin dict[str, V] / list[E] is raw on both, and an unauthored spec follows the topo axis. Holds at every depth. Strictly narrower than validate.
Equality eq Value-based __eq__ / __hash__ via sym_eq / sym_hash (vs. identity).
Mutability attr_write, frozen attr_write gates direct obj.x = v; frozen seals the whole object against sym_rebind too.
Attribute surface attr_read Dotted obj.x access (vs. obj.sym_getattr('x') only).

The default is the flat, validated endtopo=False, attr_read=True, frozen=False, eq=True, order=False, validate=True, with attr_write FOLLOWING the topo axis (True under topo=False, False under topo=True; an explicit attr_write= pins it for the subtree). A bare class Foo(pg.Object) is a mutable, validated, value-equal flat value object — dataclass/pydantic-shaped, with reference semantics and no tree; obj.x = v is a validated write and sym_rebind(x=..., y=...) is its batch form — which also accepts nested path keys (sym_rebind({'model.units': 64})) and rebinder callables, so writes descend regardless of mode. topo=True is the single opt-in rung that adds the symbolic tree (and flips the write posture to rebind-first); frozen=True is the immutability knob in both modes.

Not exactly @dataclass: the deliberate divergences

"Flat dataclass semantics" is the mental model, not a byte-for-byte contract. Where @dataclasses.dataclass and pydantic disagree, flat pg.Object consistently sides with pydantic — the reference for the validated-model ergonomics this library targets:

Aspect @dataclass pydantic v2 pg.Object (flat and topo=True alike)
Synthesized __init__ positional + keyword, declaration order keyword-only keyword-only (a teaching TypeError names the kwargs spelling; positional construction = an explicit __init__, below)
z: list = [] (mutable literal default) rejected at class creation ("use default_factory") accepted; deep-copied per instance accepted; deep-copied per instance (default_factory also supported)
c: Client = CLIENT (arbitrary-type default) shared (plain class attribute) shared (template instance reused) shared (template instance reused; unpicklable defaults legal — default_factory for a per-instance copy)
Validation / coercion none yes yes (validate=True default)
__match_args__ derived absent (positional patterns fail) derived; a class-body declaration wins

Two of these deserve their WHY:

  • Keyword-only construction. Positional synthesis makes field declaration order silent public API — reordering fields (or inserting one in a base class) rebinds every positional call site, with no error when adjacent fields share a type. Pydantic made the same call. The sanctioned positional spelling is an explicit __init__ ("Adding a positional __init__"), where the contract is visible in source and immune to reordering.
  • Mutable defaults are copied, not banned. @dataclass rejects the literal spelling because its default would be a shared class attribute and it has no per-instance hook to copy at. pg.Object (like pydantic) runs a construct funnel per instance anyway, so each construct deep-copies the stored default — Foo().z is Foo().z is False, mutations never leak across instances, and neither the schema default nor the literal you wrote is ever aliased into an instance. (Corollary: don't compare id()s of defaults across temporary instances — CPython reuses freed addresses, so id(Foo().z) can repeat across constructs that never coexisted.)

One sharing rule that matches both neighbors: a shallow clone (sym_clone(), the default) reference-keeps a flat object's raw container members, exactly as copy.copy shares a dataclass's — use sym_clone(deep=True) for an isolated copy.

Sliding down the spectrum

Each rung below shows the deltas from the default and a minimal example.

Rung 0 — plain mutable dataclass. Flat (the topo=False default) with validation off; writes are already on by default. Behaves like @dataclasses.dataclass: synthesized init, free mutation, values stored verbatim, raw containers, reference semantics (a second holder is just another reference), no symbolic tree.

class Point(pg.Object, validate=False):
    x: int
    y: int = 0

p = Point(x='oops')      # no error — value lands verbatim
p.x = 2                  # mutable

Rung 1 — validated mutable record (the default). Everything at defaults. Validated, value-equal, hashable, and mutable: attr_write follows the topo axis, so a flat object accepts obj.x = v (validated, like a mutable pydantic model — type errors fire at construction and assignment), and sym_rebind(x=..., y=...) is the batch form (same validation, one coalesced on_sym_change) — reaching nested values too, via path keys or a rebinder callable.

class Money(pg.Object):
    amount: int
    currency: str = 'USD'

m = Money(amount=5)
Money(amount=5) == Money(amount=5)    # True (value equality)
m.amount = 9                          # validated in-place write
m.amount = 'nope'                     # TypeError: expect int, got str
m.sym_rebind(amount=5, currency='EUR')  # batch form, one change event
m.sym_rebind({'meta.tag': 'x'})       # SymbolicModeError — path keys need a tree

Since the default is mutable and hashable, don't mutate an object while it serves as a dict/set key (the same hazard any hashable-mutable value has) — take the next rung for construct-once values.

Rung 2 — immutable value object. Pin attr_write=False for a read-only dotted surface (sym_rebind still works — frozen alone seals it), or frozen=True to seal the object completely at construction. To derive a modified value, use sym_clone(override=...) — the dataclasses.replace() analog, available in both topo modes:

class Snapshot(pg.Object, frozen=True):
    amount: int
    currency: str = 'USD'

s = Snapshot(amount=5)
s.amount = 9                                      # WritePermissionError
s2 = s.sym_clone(override=dict(currency='EUR'))   # new Snapshot; s unchanged

Rung 3 — full symbolic node. The opt-in rung: add topo=True (which also makes unauthored container fields wrap, and is inherited by subclasses). The object and its nested containers participate in the symbolic tree: topo_path, sym_rebind, sym_diff, contextual value resolution, and on_sym_change notifications all apply to the field subtree.

class Money(pg.Object, topo=True):
    amount: int
    currency: str = 'USD'

class Order(pg.Object, topo=True):
    items: list[Money]

o = Order(items=[Money(amount=5)])
o.items[0].topo_path                   # 'items[0]' — tree-addressable
o.sym_rebind({'items[0].amount': 7})      # path-targeted mutation

Rung 4 — frozen. Add frozen=True to seal at construction. Now even sym_rebind raises (unless scoped under pg.as_sealed(False)). Use for shared constants and snapshot safety.

class Frozen(pg.Object, topo=True, frozen=True):
    x: int

Frozen(x=1).sym_rebind(x=2)               # WritePermissionError

The configuration matrix

The knobs compose — the rows below are common combinations, not mutually exclusive modes. "Deltas" are the kwargs you set; everything unlisted stays at its default.

Semantic Class-arg deltas Direct write Validate Symbolic tree Equality Reach for it when
Plain dataclass validate=False value You genuinely want raw, verbatim values + free mutation (not a speed win — see the note below).
Validated record (default) (none) ✓ (validated; sym_rebind batches) value, hashable Most subclasses.
Immutable value attr_write=False (or frozen=True to seal rebind too) — (use sym_clone(override=...)) value, hashable Construct-once values; safe dict/set keys.
Symbolic node topo=True — (use sym_rebind) value, hashable Trees: paths, adoption, path-targeted rebind, patching, search.
Frozen node topo=True, frozen=True — (sealed) value, hashable Shared constants; defend against any post-construction change. (frozen=True alone seals a flat object too.)
Identity object eq=False per other knobs per per identity Fields are unhashable, or node identity (not value) is the key.
Validated, raw storage x: dict[str, V] per value Type-check the value but keep type(x) is dict for third-party interop.

The spectrum is a semantic dial, not a performance one. Turning validate off, or raw storage (or the topo axis on or off), changes meaning (raw vs. validated / wrapped values, flat vs. tree), not speed in any meaningful way. Every pg.Object, at every rung, carries the same symbolic substrate, and the native core runs it at speeds that make the rung a poor lever for performance — topo=False and topo=True measure at parity. Pick the rung whose meaning you want; if performance is the question, see the performance report.

Field-level overrides

validate is tunable per field, and storage is per-field by annotation; a per-field setting wins over the topo-axis default (resolution is materialized at class-creation time). So a flat class can wrap one field, or a symbolic class hold one raw field:

class Mixed(pg.Object, topo=False):
    raw: dict                              # topo=False default → raw
    wrapped: pg.Dict[str, Any]             # annotation → wrapped

The equality/repr surface is field-tunable too — pg.field(compare=False), hash=False, and repr=False drop a field from sym_eq / sym_hash / format, and init=False moves a field out of __init__ and out of symbolic storage entirely. See Field-level behaviors for the full set.

For the deeper treatment of the topo axis — the dataclass-to-symbolic spectrum, the single-tree-position divergence, and the implementation-vs-semantics analysis — see the pg.Object semantic spectrum companion doc. The sections below are the practical authoring guide for each knob and declaration form.

How to subclass pg.Object

A minimal subclass declares fields with type annotations; __init__ is synthesized as keyword-only from those declarations:

class Message(pg.Object):
    text: str
    sender: str = 'user'

Message(text='hi', sender='ai')      # OK
Message('hi')                         # TypeError at runtime; reportCallIssue under pyright

Both the runtime constructor and pyright's synthesized init treat schema fields as keyword-only arguments. This is the intended contract — see Inheritance for when to override __init__ for positional ergonomics.

Three forms of field declaration

class A(pg.Object):
    # 1. Bare annotation. Cleanest for the common case — annotation drives
    #    the runtime ValueSpec, default (if any) follows `=`.
    name: str
    count: int = 0

    # 2. `typing.Annotated` for attaching a docstring without leaving the
    #    annotation form. The first slot is the type; subsequent string
    #    slots become the field's `description`. Use when the field's
    #    semantics need a one-liner explanation.
    tag: Annotated[str, 'Short identifier shown in logs and the tree view.']

    # 3. `pg.field(...)` for anything bare / Annotated can't express:
    #    - mutable defaults via `default_factory`
    #    - runtime constraints beyond the annotation (e.g. regex, ranges)
    #    - `init=False` / `repr=False` / `compare=False` etc.
    #    - explicit `metadata` dict
    items: list[int] = pg.field(default_factory=list)
    email: str = pg.field(
        value_spec=pg.typing.Str(regex=r'.+@.+'),
        doc='User email, validated at construction.',
    )

When to use which

  • Bare annotation: the field has no docstring and the annotation already captures the type. Reach for this first.
  • Annotated[T, 'doc']: the field needs a one-line docstring but nothing else. Cheaper visual cost than pg.field(doc=...).
  • pg.field(...): anything beyond type + default + docstring — factories, value-spec overrides, init/repr/compare/hash flags, metadata. Also use for derived fields (init=False).

You can combine the forms: Annotated[T, pg.field(...)] puts the pg.field descriptor inside an Annotated slot, useful when you want the type to remain visually leading. Most pygx code uses the bare pg.field form.

Bare annotations and Annotated forms internally desugar to pg.field() with all flags at defaults — no functional difference for the common case.

What a static default does per shape (default vs default_factory)

Static defaults copy selectively, matching pydantic v2 (#664):

  • Recognized copyable shapes deep-copy per instance — builtin containers (dict / list / set / tuple / bytearray, subclasses included, opaque elements inside copied too), symbolic values (pg.Object / pg.Dict / pg.List, via their own copy protocol — contextual markers like pg.contextual.Placeholder ride this arm), dataclass instances, and types that opt in by extending pg.CopyIfDefault. Mutating one instance's default never leaks into another.
  • Arbitrary-type defaults are shared by reference — the template instance is reused across every construct (defining __deepcopy__ does not opt a type into copying — same as pydantic; extending pg.CopyIfDefault is the declared per-type opt-in). This makes unpicklable defaults (locks, client handles, connections) legal:

    class Service(pg.Object):
        client: HttpClient = _DEFAULT_CLIENT  # ONE shared instance
        retries: int = 3
    

The footgun is mutable state on a shared template: if each instance must own its object, say so with default_factory — the explicit per-instance escape hatch:

```python
class Service(pg.Object):
    client: HttpClient = pg.field(default_factory=HttpClient)
```
  • Factory output is stored as-is (validated, never copied again).

Generic classes (G[int] validates at runtime)

A generic pg.Object subclass may declare TypeVar-typed fields; on the unparameterized class they validate gradually (the TypeVar's bound, its constraints-union, or Any). Subscripting with concrete args mints a cached, real subclass whose fields validate against the bound types — pydantic parity — and class C(G[int]) inherits the substituted fields by ordinary subclassing:

T = typing.TypeVar('T')


class Box(pg.Object, typing.Generic[T]):
    v: T | None = None
    xs: list[T] = []


Box[int](v=5)  # OK — a cached subclass named 'Box[int]'
Box[int](v='x')  # TypeError: Expect int
Box(v='x')  # OK — the unparameterized class stays gradual

Parameterized instances serialize under a parameterized _type tag ('mod.Box[int]') and round-trip with class identity preserved; a partial subscription (Box[S]) stays a typing alias for annotation positions. Declare optional TypeVar fields as v: T | None = None — a bare v: T = None fails at mint time when the substituted type rejects None (defaults validate eagerly).

Stdlib scalar fields (datetime, UUID, Decimal, Path)

datetime.datetime / date / time / timedelta, uuid.UUID, decimal.Decimal, and pathlib.Path annotations map to first-class specs (pg.typing.Datetime etc.) with pydantic-v2-parity lax coercion — ISO-8601 strings (and POSIX timestamps for datetime), canonical UUID/decimal/path strings — while strict=True (field or class level) accepts only the exact type. On the wire they serialize as tagged human-readable strings ({"_type": "datetime.datetime", "value": "2026-01-02T03:04:05"}) instead of pickled blobs, and JSON schemas carry the standard format markers (date-time, uuid, ...):

class Event(pg.Object):
    at: datetime.datetime
    uid: uuid.UUID
    amount: decimal.Decimal


Event(at='2026-01-02T03:04:05', uid='550e8400-...', amount='1.50')

Structured dict fields via TypedDict

A typing.TypedDict annotation declares a closed dict schema in one stroke: keys become const fields, extra keys are rejected, and NotRequired / total=False keys become noneable fields defaulting to None (an absent key reads back as None):

from typing import NotRequired, TypedDict

class Retry(TypedDict):
    max_attempts: int
    backoff: NotRequired[float]

class HttpClient(pg.Object):
    retry: Retry              # validated dict; stays raw like `dict[str, V]`
    retry2: pg.Dict[Retry]    # same schema, field IS the symbolic container

Like dict[str, V], the bare spelling does not force wrapping; pg.Dict[SomeTypedDict] declares the field is a pg.Dict (forces symbolic storage in both modes, like pg.Dict[str, V]). pg.typing.Dict(SomeTypedDict) builds the same value spec explicitly. Recursive TypedDicts are rejected (a dict schema is a tree, not a graph); generic TypedDicts substitute their type parameters (Movie[int]), with unbound parameters erasing to Any.

PEP 728 open TypedDicts are honored: extra_items=T appends a typed wildcard (extra keys allowed, validated as T, inherited by subclasses), while closed=True and the pre-PEP default reject extra keys. PEP 695 type aliases (type Retry = ...) are transparent everywhere an annotation is read — an alias of pg.Dict[str, V] still declares the symbolic container.

Class-level behaviors

A subclass's behavior is tuned via keyword arguments to the class statement. These flow into _ObjectOptions and are inherited by further subclasses (each level overlays its own overrides):

class Counter(pg.Object, attr_write=True):
    count: int = 0

class FrozenCounter(Counter, frozen=True):
    pass

class IdentityBag(pg.Object, eq=False):
    """Compared by identity, not value — usable as dict/set keys."""
    items: list[int] = pg.field(default_factory=list)
Class kwarg Effect Default
topo Top-level axis, True or False. topo=True makes instances symbolic-tree nodes (one tree position per node, topo_path / topo_parent / topo_root, contextual resolution, change notification; an unauthored container spec stores symbolic — an explicit dict / list annotation stays raw). topo=False is a flat, reference-semantics object: no tree, no single-position rule, raw members; the tree-position members (topo_path / topo_parent / topo_root / topo_ancestor and their setters) and contextual resolution raise SymbolicModeError, while the value-shaped writes all work — sym_rebind accepts plain field names, nested path keys, and rebinder callables, and sym_clone(override=...) is the dataclasses.replace() analog. topo=None (unspecified) inherits the base class's mode. None (inherit; root pg.Object is False)
attr_read obj.field_name returns the symbolic field's value. When False, attribute access raises and obj.sym_get(...) is required. True
attr_write obj.field_name = v updates symbolic state. When False, raises WritePermissionError. Gates only the dotted surface — sym_rebind is sealed by frozen alone. follows not topo (True for flat, False for symbolic); an explicit value pins the subtree
frozen Object is sealed at construction; subsequent sym_rebind / assignment raises unless under pg.as_sealed(False). False
eq __eq__ / __ne__ / __hash__ delegate to sym_eq / sym_hash. When False, fall back to identity. True
order Synthesize < / <= / > / >= (à la @dataclass(order=True)): lexicographic comparison over the compare=True fields in declaration order — the same fields that feed sym_eq. A different type yields NotImplemented (Python then raises TypeError). Orthogonal to sym. Delegates to sym_lt / sym_gt, so a < b and pg.lt(a, b) agree for same-type operands. False
validate Whether values flow through ValueSpec.apply at __init__, reassignment, and rebind. When False, values land verbatim — type check, coercion, and default-filling are all skipped. True
init PEP 681 class keyword. init=False opts the subclass out of pyright's dataclass_transform __init__ resynthesis, so the parent's explicit __init__ resolves via MRO instead. Runtime no-op. See §Inheriting a parent's explicit init. True

Per-field dict / list storage is declared by the ANNOTATION (see §Container storage below), falling back to the topo axis when nothing declares it. There is no class-level wrapping kwarg — the former class-level symbolize= keyword has been removed; use topo=False (or per-field a builtin dict / list annotation) instead.

The defaults are deliberate: flat (dataclass/pydantic-shaped), validated, mutable-with-validation, value-equal-by-default — the contract a newcomer expects from a modeled class. Reach for these knobs (topo=True first among them, for the symbolic tree; frozen=True for immutability) when you genuinely want different behavior, not as defensive defaults.

Container storage — the annotation decides

Whether a raw dict / list supplied to a field is stored as-is or wrapped into a pg.Dict / pg.List (so it participates in the symbolic tree — topo_path, sym_rebind, sym_diff, contextual lookup, change notification) is decided by the field's annotation:

Annotation Storage Notes
x: pg.Dict[str, V] / pg.List[E] symbolic On BOTH axes — a flat class stores the symbolic container too (held by reference, not adopted).
x: dict[str, V] / list[E] raw On BOTH axes — an explicit builtin annotation means what it says.
x: Any, x: dict-less specs, pg.field(value_spec=...) the topo axis Nothing declared storage, so topo=True wraps and topo=False stays raw.
class Foo(pg.Object, topo=True):
    a: pg.Dict[str, int]        # symbolic — declared
    b: dict[str, int]           # raw — declared
    c: typing.Any               # unauthored → follows topo (here: wraps)

f = Foo(a={'x': 1}, b={'y': 2}, c={'z': 3})
type(f.a)   # pg.Dict
type(f.b)   # dict
type(f.c)   # pg.Dict

The law holds at every depth, not just at a field's top level:

class Nested(pg.Object, topo=True):
    m: pg.Dict[str, dict[str, int]]   # outer symbolic, inner RAW
    n: pg.Dict[str, typing.Any]       # inner unannotated → wraps
    o: pg.Dict[str, typing.Any, False]  # `auto_wrap=False` → inner verbatim

pg.Dict[K, V, auto_wrap] takes an optional trailing bool: it governs raw containers landing in positions the annotation does not constrain to a container type. It defaults to True, so pg.Dict[str, Any] is pg.Dict[str, Any, True].

Removed in 0.4: pg.field(wrap=...) and Field.enable_wrap. The annotation says everything the flag said, and says it at every depth rather than only at the field boundary. Migration: a field that needs the symbolic container is respelled pg.Dict[...] / pg.List[...]; a field that needs raw storage is spelled with the builtin dict / list. A bare x: dict on a topo=True class now means RAW — this is a semantic change, not a respelling. See Migration to 0.4.

Earlier still, this flag was spelled symbolize; that name was removed in 0.2.

Where storage has effect: only on fields whose values are dict or list (or contain nested dict/list). Declaring raw storage does NOT disable:

  • Type-validation against the field's ValueSpec (use validate=False for that).
  • Type-owned coercion — the intfloat widening intrinsic to pg.typing.Float, and "into a type you own" coercions declared by a target type's __pg_accept__(value) classmethod (e.g. KeyPath/Html accepting str). These still fire.
  • Default-filling for missing keys (that lives in the apply pipeline gated by validate).

The (validate, storage) combinations. They compose into three real behaviors (the fourth collapses because validate=False short-circuits before the wrapping callback can fire):

validate storage Behavior
True symbolic Wrap, validate, fill defaults.
True raw Validate against the schema, but leave the value raw. Useful when you want type safety + raw access (e.g. for isinstance(x, dict) interop, performance, or aliasing semantics).
False * Whole apply pipeline skipped. Values land verbatim; storage is moot.

Container-level on pg.Dict / pg.List. The flag also exists at the container surface as a persistent property — pg.Dict(..., wrap=False) builds a symbolic outer container with raw children that stays consistent across __init__ and subsequent __setitem__ / update / sym_rebind:

d = pg.Dict({'a': {'k': 1}}, wrap=False)
type(d['a'])             # <class 'dict'>
d['b'] = {'k': 2}
type(d['b'])             # <class 'dict'> — also raw

Combining wrap=False with value_spec= runs validation against the schema without wrapping children (same as the field-level (True, False) row above).

pg.from_json(..., wrap=False). The same flag controls JSON-side deserialization for schemaless layers. _type-tagged typed payloads always dispatch through the class's own apply pipeline (so a nested pg.Object still gets constructed), and embedded __symbolic__: true markers force-wrap a specific subtree regardless of the caller's flag. See pg.from_json for details.

When to reach for wrap=False.

  • The field stores plain JSON-ish data that you hand off to a library that does type(x) is dict checks.
  • The field is a perf-sensitive hot path and the symbolic tree features (sym_rebind, sym_diff, change notification, contextual) aren't used on it.
  • You want obj.x is original_dict to hold for aliasing reasons (the default wrapping breaks identity by creating a new pg.Dict).

When not to. If you use sym_rebind / topo_path / contextual lookup / change notification on the field's subtree, leave it on. wrap=False is a sharp escape hatch, not a general-purpose performance knob.

Design note: why storage lives on the SPEC, not on Field

Container storage was originally a Field-level flag (pg.field(wrap=...), resolved against the class's topo axis and materialized onto each field at class creation). It moved onto the value spec in 0.4, because the flag could not express the law it was being asked to enforce:

  • A field-level bit only speaks at the field boundary. pg.Dict[str, dict[str, Any]] — wrapped-of-raw — needs storage stated at an INNER position. The spec tree has a node there; the field does not.
  • The spec was mode-blind, so the information was erased. Before 0.4, pg.Dict[str, V] and dict[str, V] produced byte-identical specs, and symbolic-ness survived only as a boolean on the field. By the time the funnel walked into a nested position, nothing distinguished them.
  • Two sources of truth disagreed. The annotation and the flag could contradict each other (pg.Dict[...] + wrap=False was a class-creation error precisely because of this), and at the field boundary the flag won — which is why a bare x: dict kept wrapping even after the annotation said otherwise.

The spec now carries a TRI-STATE symbolic: True (a pg.Dict[...] / pg.List[...] annotation), False (an explicit builtin dict / list), or None — UNAUTHORED, meaning no annotation spoke for this spec (a value_spec= passed directly, an untyped field, a spec built in library code). Only the two explicit states override the topo axis; None defers to it. The distinction is load-bearing: collapsing "declared raw" into "never declared" would stop every directly-built pg.typing.Dict(...) from wrapping.

Specs are per-declaration objects (subclassing clones rather than shares), so storing the declaration on the spec does not leak across classes — the sharing hazard that motivated the Field-level surface does not apply.

Forwarded into pg.Object.__init_subclass__ and consumed by the serialization registry:

  • serialization_key — explicit registry key for JSON round-trip (overrides the default <module>.<qualname>).
  • additional_keys — extra registry aliases (typically for renames / migrations).
  • add_to_registry — set False on bases that should not be deserialized directly.
  • user_cls — used by class-wrapper machinery; you almost never set this.

These are orthogonal to the field-level flags; both can appear on the same class statement.

Field-level behaviors

pg.field(...) exposes per-field flags that mirror or extend dataclasses.field. Bare and Annotated declarations get all flags at default; reach for pg.field only when you need a non-default flag.

Param Purpose
default Initial value; required-when-omitted unless default_factory is set.
default_factory Zero-arg callable producing the default per instance (lists, dicts, time stamps). Mutually exclusive with default.
doc One-line docstring. Equivalent to Annotated[T, doc].
metadata Arbitrary dict carried on the schema field — useful for downstream tools (serialization hints, etc.).
value_spec Explicit pg.typing.ValueSpec overriding the annotation-derived spec. Annotation still drives static types.
transform (value) -> value hook run after type validation.
alias Wire-layer key alias: accepted by from_json, emitted by to_json(by_alias=True) and JSON Schema. Attribute access and __init__ keep the field name.
validator (value) -> None after check, called with the final (coerced/wrapped) value on construct, assignment, and sym_rebind; raise to reject (re-raised path-suffixed as the same exception class). Return value ignored — transformation is transform's job.
repr / compare / hash Mirror dataclasses.field. Exclude the field from format() / sym_eq / sym_hash.
init=False Drop the field from __init__; store on self.__dict__ instead of _sym_attributes. The class manages it (typically via on_sym_ready).
clone Only meaningful with init=False: preserve the value through sym_clone instead of reseeding from default.
validate Force / suppress ValueSpec.apply regardless of init.

Non-fields: use typing.ClassVar

Annotated class attributes that are configuration, not schema fields, must be marked typing.ClassVar so dataclass_transform skips them:

class MyOperator(pg.Object):
    OPERATOR_STR: typing.ClassVar[str] = '+'      # config, not a field
    OPERATOR_FN: typing.ClassVar[Callable] = ...   # config, not a field

    x: Any                                          # schema field
    y: Any                                          # schema field

Without ClassVar, pyright treats OPERATOR_STR and OPERATOR_FN as required-but-defaulted schema fields, pollutes the synthesized __init__ signature, and (worse) triggers the "fields without default values cannot appear after fields with default values" rule on later subclasses that add required fields.

pg.Object itself follows this convention for its __sym_options__, __infer_fields__, __auto_schema__, _exclude_from_repr, _non_symbolic_fields configs.

Inheritance

Three patterns come up in nearly every non-trivial subclass hierarchy.

Overriding a field's default value

When a subclass overrides a parent field's default, re-annotate to keep pyright's synthesized __init__ in sync:

class Message(pg.Object):
    text: str
    sender: str = pg.MISSING_VALUE

class UserMessage(Message):
    sender: str = 'user'    # good — re-annotated, pyright picks up the new default

class UserMessage2(Message):
    sender = 'user'         # bad — bare assignment, pyright still sees `sender` as required

Bare-assignment overrides are silently invisible to dataclass_transform synthesis. pygx emits a runtime warning when it detects this (see #101); the fix is always to re-annotate.

Inheriting a complex parent spec: use pg.typing.Inherit

When the parent field is declared with an Enum, regex, numeric bound, or any other rich value_spec, a re-annotation like model: str = 'a' creates a fresh Str spec on the subclass that cannot extend the parent's Enum — class creation fails with TypeError: ... cannot extend ...: incompatible type. Restating the full parent spec on every subclass leaks the source-of-truth (e.g. the Enum's value set) into the override.

Use pg.typing.Inherit to keep the parent's spec verbatim and only swap the default:

class LM(pg.Object):
    model: Annotated[
        str, pg.field(value_spec=pg.typing.Enum(SUPPORTED_MODEL_IDS))
    ]

class Claude46Opus(LM):
    model: pg.typing.Inherit = 'claude-opus-4-6'  # inherits Enum, default='…'

class Gpt5(LM):
    model: pg.typing.Inherit = 'gpt-5'            # ditto, no Enum restatement

Static type checkers see Inherit as Any, so pyright honors the new default (no reportCallIssue) and doesn't fire reportIncompatibleVariableOverride. The runtime still validates assignments against the inherited Enum.

Errors at class creation if (a) no parent field with the same key exists, or (b) no default value is supplied — Inherit without a default is a no-op and intentionally rejected. For parent fields with plain types (int, str, …), prefer the simple re-annotation form (x: int = 1) — Inherit is for cases where the parent spec is non-trivial to restate.

Inheriting a parent's explicit init

A common library pattern declares a **kwargs-forwarding __init__ on a base class to route flat kwargs into a nested field, then has concrete subclasses that only override a default:

class LM(pg.Object):
    model: str
    sampling_options: SamplingOptions = pg.field(default_factory=SamplingOptions)

    def __init__(self, **kwargs: Any) -> None:
        # Routes flat `thinking=`, `effort=`, … into `sampling_options`.
        super().__init__(**kwargs)

class Claude47Opus(LM, init=False):
    model: pg.typing.Inherit = 'claude-opus-4-7'

Claude47Opus(api_key='…', thinking=True)   # accepted by pyright

Without init=False, pyright would resynthesize Claude47Opus.__init__ from its schema fields only — losing the parent's **kwargs escape hatch and flagging thinking=True as reportCallIssue: No parameter named "thinking". PEP 681's init=False class keyword tells pyright to skip resynthesis for that subclass; __init__ then resolves through the MRO to the parent's explicit definition, with the parent's full typed signature still enforced.

At runtime, init=False is a no-op — Python's MRO already resolves __init__ lookups the same way; the keyword exists purely to align the static-analysis view with the runtime view.

When to use it. Subclass adds no new fields (or only overrides defaults via re-annotation / pg.typing.Inherit) and the parent has an explicit __init__ that pyright must not shadow.

When not to. Subclass adds new schema fields you want in the synthesized init signature. init=False disables synthesis entirely — those new fields would only be reachable via the parent's **kwargs. For new fields with their own kwargs surface, leave init at its default and let the synthesizer build a fresh init.

Adding a positional __init__ for ergonomic primary fields

If a class has a small, semantically obvious "primary" field — the kind users naturally write as the first positional argument — provide an explicit __init__ that forwards to super().__init__(**kwargs):

class Label(HtmlControl):
    text: str | Html
    tooltip: Tooltip | None = None
    link: str | None = None
    target: str | None = None

    def __init__(
        self,
        text: str | Html,
        tooltip: Tooltip | None = None,
        link: str | None = None,
        target: str | None = None,
        **kwargs,
    ):
        super().__init__(
            **dict(text=text, tooltip=tooltip, link=link, target=target),
            **kwargs,
        )
class BinaryOperator(Operator):
    x: Any
    y: Any

    def __init__(self, x: Any, y: Any, **kwargs):
        super().__init__(**dict(x=x, y=y), **kwargs)

Rule of thumb: one or two leading positional fields. Beyond that the positional call site becomes harder to read than the kwargs form, and the explicit __init__ becomes maintenance burden when the schema evolves.

Classes without a clear primary field should stay kw-only — let the synthesized __init__ from dataclass_transform do the work.

Calling super().__init__: use the **dict(...) idiom

When forwarding schema fields to super().__init__(), use super().__init__(**dict(field=field, ...), **kwargs) rather than super().__init__(field=field, ..., **kwargs). Reason:

When super() resolves to a pg.Object subclass that doesn't itself declare an explicit __init__, pyright synthesizes the parent's init closed over only that parent's local fields — it doesn't accept arbitrary subclass field names. So super().__init__(text=text, tooltip=tooltip) gets flagged reportCallIssue: No parameter named "text" even though the call is correct at runtime (Python eventually dispatches to pg.Object.__init__(**kwargs), which validates against the full schema).

The **dict(text=text, tooltip=tooltip) form passes the same kwargs but hides their names behind a dict literal. Pyright sees the spread as **Mapping[str, Any] and does not validate the keys — the runtime call is identical. One extra layer of source-level indirection buys silent, ignore-free static checking.

When you need a # pyright: ignore[reportCallIssue] anyway

A small set of cases the **dict(...) form doesn't cover:

  • Subclass passes positional arguments to super().__init__() (rare in pygx — almost everything goes by keyword).
  • Subclass calls a sibling class's constructor where the field names collide with framework keywords (e.g. allow_partial).
  • The forwarded kwargs include a field that fails an unrelated check (reportArgumentType typically — that's a different rule and a different fix).

In those cases keep the # pyright: ignore[reportCallIssue] (or # pyright: ignore[<other-rule>]) — but reach for them only after trying the **dict(...) form first.

Quick reference

Situation Pattern
Type + default, no docstring needed Bare annotation: name: str = ''
Type + one-line docstring field: Annotated[T, 'doc']
Mutable default, validation, init/repr/compare flags field: T = pg.field(...)
Class-level config attribute (not a schema field) name: typing.ClassVar[T] = default
Read-only dotted surface (rebind still works) class C(pg.Object, attr_write=False): ...
Sealed-on-construction class class C(pg.Object, frozen=True): ...
Identity-equal class class C(pg.Object, eq=False): ...
New class, all fields kw-only Don't write __init__. Let the synthesizer handle it.
New class, primary positional arg (1–2 fields) Explicit __init__, forward via **dict(field=field, ...), **kwargs
Subclass changing a parent default Re-annotate: field: T = new_default
Subclass changing a parent default w/ a complex spec (Enum, regex, ...) field: pg.typing.Inherit = new_default
Subclass inheriting parent's explicit __init__(**kwargs) (no new fields) class Sub(Parent, init=False): ...
super().__init__(field=field) flagged reportCallIssue Switch to **dict(field=field), **kwargs — don't add # pyright: ignore
Direct pg.Object(...) construction with unknown kwarg Pyright correctly flags it. Fix the call site.