Extending¶
To encode and decode types other than those natively supported, structtype provides two mechanisms:
Custom-type protocol — the type itself declares
struct_dump/struct_validatemethods. Pydantic’smodel_dump/model_validateare also recognized. This is the primary mechanism: it works for any custom type and nests automatically.Per-field Serializers — an
Annotated[X, Serializer(dump=..., load=...)]attaches a Serializer to a single field. This is the escape hatch for types that can’t declare methods, such as builtins or third-party types you don’t control.
Custom type protocol¶
Types are extended by implementing two methods:
struct_dump— an instance method converting the object into a value composed of natively supported types.struct_validate— a classmethod converting a value composed of natively supported types back into an instance of the custom type.
Both are detected by duck-typing, so no base class or registration is
required. During encoding, the object’s struct_dump result is serialized
normally; during decoding, the decoded value is passed to struct_validate.
Note
Since no custom-type information is carried in the message itself, decoding requires typed decoding — the custom type must appear in the schema (e.g. as a field annotation).
Here we define a MyType that serializes as a pair of floats, similar to
the pair-of-floats representation of a complex:
from structtype import Struct
from typing import Any
class MyType:
def __init__(self, real: float, imag: float) -> None:
self.real = real
self.imag = imag
def struct_dump(self) -> Any:
# Return a value composed of natively supported types
return (self.real, self.imag)
@classmethod
def struct_validate(cls, obj: Any) -> "MyType":
# ``obj`` is composed of natively supported types
return cls(obj[0], obj[1])
class Message(Struct):
value: MyType
msg = Message(MyType(1.0, 2.0))
# Encode and decode a message using the protocol
buf = msg.struct_dump_json()
msg2 = Message.struct_validate_json(buf)
assert msg2.value.real == 1.0 and msg2.value.imag == 2.0
The protocol works wherever the type appears — nested inside containers, inside other structs, or behind optional types:
class Container(Struct):
values: list[MyType]
maybe: MyType | None = None
c = Container([MyType(1.0, 2.0)])
assert c.struct_dump_json() == b'{"values":[[1.0,2.0]],"maybe":null}'
c2 = Container.struct_validate_json(b'{"values":[[3.0,4.0]],"maybe":null}')
assert c2.values[0].real == 3.0 and c2.values[0].imag == 4.0
Pydantic models¶
pydantic models are supported out of the box. They use pydantic’s native
model_dump / model_validate methods, which structtype recognizes
automatically:
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int = 0
class Message(Struct):
user: User
msg = Message(User(name="Alice", age=30))
assert msg.struct_dump_json() == b'{"user":{"name":"Alice","age":30}}'
msg2 = Message.struct_validate_json(b'{"user":{"name":"Bob"}}')
assert msg2.user.name == "Bob"
Annotation escape hatch¶
Some types can’t implement the protocol. This includes builtins like
complex and third-party types you don’t control. For these, attach a
Serializer to the field itself:
from typing import Annotated
from structtype import Struct, Serializer
def dump(c: complex) -> tuple[float, float]:
# Convert the value into natively supported types
return (c.real, c.imag)
def validate(obj) -> complex:
# Convert natively supported types back into the value
return complex(obj[0], obj[1])
class Message(Struct):
value: Annotated[complex, Serializer(dump=dump, load=validate)]
msg = Message(complex(1.0, 2.0))
assert msg.struct_dump_json() == b'{"value":[1.0,2.0]}'
msg2 = Message.struct_validate_json(b'{"value":[1.0,2.0]}')
assert msg2.value == complex(1.0, 2.0)
dump and load may be provided independently:
A
dump-only serializer controls encoding; decoding falls back to the protocol (if the type implements one), or fails with aValidationError.A
load-only serializer controls decoding; encoding falls back to the protocol, or fails with aTypeError.
Serializers are per-field. Different fields may use different Serializers for the same type, and a field’s Serializer applies wherever the annotated type appears within that field — including nested inside lists, dicts, and tuples:
class Message(Struct):
a: Annotated[complex, Serializer(dump=dump_pair)] # -> [real, imag]
b: Annotated[complex, Serializer(dump=dump_real)] # -> real
values: list[Annotated[complex, Serializer(dump=dump_pair)]]
Serializers may only be attached to types that are not in the blocked set.
structtype validates this when the class is created. The restriction
applies even when the annotation uses an empty Serializer() with no
load or dump callback:
the type is one of the blocked native types:
bool,int,float,str,list,dict,tuple(parameterized or not),TypedDict,NamedTuple,frozendict,Any, or aLiteralvalue — e.g.Annotated[int, Serializer(dump=...)],the annotation attaches the Serializer to a union or optional type rather than a concrete type (see the placement rule),
two different
dump=Serializers apply within a single field, e.g.tuple[Annotated[complex, Serializer(dump=a)], Annotated[complex, Serializer(dump=b)]].
All other types are allowed, including bytes, bytearray,
memoryview, the datetime family,
uuid.UUID, decimal.Decimal, enums, set, frozenset,
Struct subclasses, and dataclass / attrs types. The full blocked
set is documented on the Serializer API entry.
For a custom subclass such as MyStr(str), load is skipped when
struct_validate receives an existing MyStr instance. A plain str
value, and every JSON string, is passed to load so it can produce the
MyStr value. dump is applied consistently by both struct_dump and
struct_dump_json; this also applies when a dump callback returns another
value of the same runtime type.
Subclasses of natively supported types are classified as custom types, so
Serializers attach fine to them — see Custom formats for natively supported types below for
the recommended pattern.
Serializers are only supported on Struct fields.
StructAdapter rejects annotations containing a Serializer —
use the protocol methods on the type there, or a Struct:
StructAdapter does support Constraint annotations on concrete
types, but rejects a Constraint attached to a union or optional type at
construction (matching Struct class creation).
from structtype import StructAdapter
# Raises TypeError — use a `struct_dump`/`struct_validate` protocol method
# on the type, or a `Struct` instead.
StructAdapter(Annotated[complex, Serializer(dump=dump, load=validate)])
Wrapping arbitrary JSON data¶
A field may hold arbitrary JSON data — nested objects or arrays whose schema
is not known up front. If no dedicated type is needed, an Any annotation
already covers this: the value decodes to plain Python types and re-encodes on
output. Use a wrapper type with a Serializer when the field should be a
real, named type (type checking, introspection, methods on the value). The
wrapper works with both the JSON and the Python path:
Recipes¶
The escape hatch can be defined once as an annotated alias and reused across a
project. These are common Python stdlib types that structtype doesn’t
natively support, with a Serializer for each:
import fractions
import re
import types
from collections import deque
from typing import Annotated
from structtype import Serializer
Complex = Annotated[complex, Serializer(
dump=lambda c: (c.real, c.imag),
load=lambda o: complex(o[0], o[1]))]
Fraction = Annotated[fractions.Fraction, Serializer(
dump=lambda f: (f.numerator, f.denominator),
load=lambda o: fractions.Fraction(o[0], o[1]))]
Deque = Annotated[deque, Serializer(
dump=list,
load=deque)]
Pattern = Annotated[re.Pattern, Serializer(
dump=lambda p: p.pattern,
load=re.compile)]
Range = Annotated[range, Serializer(
dump=lambda r: (r.start, r.stop, r.step),
load=lambda o: range(o[0], o[1], o[2]))]
SimpleNamespace = Annotated[types.SimpleNamespace, Serializer(
dump=vars,
load=lambda o: types.SimpleNamespace(**o))]
These aliases nest (list[Complex], dict[str, Fraction]), and the
dump / load callables must map to natively supported values. Serializer aliases are supported on Struct
fields only — StructAdapter rejects them (see above). For types you
control, prefer the struct_dump / struct_validate protocol methods.
Single-argument string-constructible types may also use
Annotated[T, Serializer(dump=str, load=T)] directly. For a field holding
arbitrary JSON data, see Wrapping arbitrary JSON data above.
JSON schema for string-encoded custom types¶
A custom type that serializes as a string needs both a Serializer and,
for a useful JSON Schema, a format. Attach the latter with
Field(json_schema_extra=...); it is applied after the generated schema and
therefore overrides it:
from typing import Annotated
from pydantic import AnyUrl
from structtype import Field, Serializer
Url = Annotated[
AnyUrl,
Serializer(dump=str, load=AnyUrl),
Field(json_schema_extra={"type": "string", "format": "uri"}),
]
pydantic.AnyUrl / HttpUrl, yarl.URL, and httpx.URL all work this way —
swap the class in dump / load and adjust the format (http,
https, …). URLs are JSON strings, so dump=str and load=AnyUrl are
all the conversion required.
Custom formats for natively supported types¶
The restriction above can be lifted cleanly by subclassing: any subclass of a
natively supported type counts as a custom type, so it accepts a
Serializer — while still behaving like the native type (isinstance
holds, and operators/methods keep working):
import datetime
from typing import Annotated
from structtype import Serializer, Struct
FMT = "%d/%m/%Y %H:%M"
class EuroDT(datetime.datetime):
@classmethod
def parse(cls, value):
if isinstance(value, EuroDT):
return value
return cls.strptime(value, FMT)
Euro = Annotated[EuroDT, Serializer(dump=lambda d: d.strftime(FMT),
load=EuroDT.parse)]
class Event(Struct):
when: Euro
msg = Event.struct_validate_json(b'{"when": "05/06/2020 14:30"}')
assert isinstance(msg.when, datetime.datetime) # full datetime API
assert msg.struct_dump_json() == b'{"when":"05/06/2020 14:30"}'
The same works for other natives — class Lower(str) (normalizing text),
class Milli(int) (fixed-unit integers), class Hex(bytes) (custom
string encodings).
How values are converted:
Whenever a field value is not already an instance of the subclass, your
loadcallable runs — on bothstruct_validate_jsondecoding and in-memorystruct_validate. Base-class instances (a plainstrordatetime) therefore pass through yourload, so make it accept them or fail cleanly.Values that are already instances of the subclass skip
loadentirely — constructing the instance yourself is always safe.
If you don’t need per-field formats, prefer implementing the
struct_dump / struct_validate protocol methods on the subclass
directly; then no annotation is required anywhere. A bare subclass without
either mechanism cannot be serialized or decoded (there is no fallback to the
native encoding).
Pydantic custom types¶
pydantic lets you define custom types via the __get_pydantic_core_schema__
protocol. structtype has no runtime dependency on pydantic, so these are
bridged from the user side with a Serializer and
pydantic.TypeAdapter:
from typing import Annotated
from pydantic import TypeAdapter
from structtype import Serializer
class Zip: # a pydantic custom type whose core schema defines validation
def __init__(self, code: str):
self.code = code
# `load` always works — it's pydantic's own validation/coercion.
# Provide a small `dump` when the type has no serializer.
PostalCode = Annotated[Zip, Serializer(
dump=lambda p: p.code,
load=TypeAdapter(Zip).validate_python)]
# ...or, if the custom type defines a serializer in its core schema,
# both sides can use TypeAdapter directly:
PostalCode = Annotated[Zip, Serializer(
dump=TypeAdapter(Zip).dump_python,
load=TypeAdapter(Zip).validate_python)]
Note
Many pydantic custom types define validation-only schemas (no
serializer), and TypeAdapter.dump_python fails on those — so prefer
providing a small dump callable yourself.
A reusable helper:
def pydantic_type(t, dump=None):
ta = TypeAdapter(t)
return Annotated[t, Serializer(dump=dump or ta.dump_python,
load=ta.validate_python)]
Like the stdlib recipes above, this applies to Struct fields only
(StructAdapter rejects Serializer annotations). pydantic BaseModel types
themselves are handled automatically via the model_dump /
model_validate protocol described above.
Migrating from hooks¶
The previous enc_hook / dec_hook callbacks were removed. Replace them
with a protocol method or a Serializer:
For types you control, implement
struct_dumpandstruct_validatedirectly on the type.For types that can’t declare methods, attach a
Serializer(dump=..., load=...)to the field’s annotation.The hooks were passed as keyword arguments to
struct_dump/struct_dump_json/struct_validate/struct_validate_json. Those arguments no longer exist; passing them raises aTypeError.