A lightweight, type-safe JSON (de)serializer for NamedTuple-based models.
serdechko is a single module that maps JSON documents onto plain
typing.NamedTuple classes and back. There is no schema DSL, no metaclass, and
no base class to inherit — your models are ordinary immutable named tuples, and
their type annotations are the schema. Deserialization failures come back as
rusty Result values instead of
exceptions, so a malformed payload is a value you handle, not a surprise you
catch.
- Your models stay plain. A
NamedTuplewith type hints is a complete model definition. Nothing to subclass, nothing to decorate, nothing to register. - Errors are values.
deserializereturnsResult[T, Exception], so failure paths are visible in the signature and compose withmap,and_then, and friends. - Batteries for real payloads.
datetime,UUID,Enum, nested models, lists of models, andOptionalfields are handled out of the box. - Escape hatches where you need them. A value can override its own
encoding with a
serialize()method, and anyEnumcan override decoding with adeserialize()classmethod. - One file, one dependency. Drop
serdechko.pyinto any project running Python 3.12+ alongsiderusty.py. - Fully tested. The test suite covers all library features.
pip install serdechkoThe distribution and the module share a name, so it is imported as it is installed:
from serdechko import deserialize, serializeIts only runtime dependency is rustypie,
imported as rusty, which provides the Result type.
Alternatively, since the entire library is a single file, you can simply copy
serdechko.py into your project.
- Quick Start
- Supported Types
- Serialization
- Deserialization
- Enums
- Nested Models and Lists
- Optional Fields and Defaults
- Error Handling
- Quick Reference
- Testing
- License
from datetime import datetime
from typing import NamedTuple, Optional
from uuid import UUID
from serdechko import deserialize, serialize
class User(NamedTuple):
id: UUID
name: str
created: datetime
email: Optional[str] = None
payload = {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
"created": "2026-01-01T12:00:00+00:00",
}
user = deserialize(User, payload).unwrap()
assert user.id == UUID("3f2504e0-4f89-11d3-9a0c-0305e82c3301")
assert user.created == datetime.fromisoformat("2026-01-01T12:00:00+00:00")
assert user.email is None
assert serialize(user) == {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
"created": "2026-01-01T12:00:00+00:00",
"email": None,
}| Annotation | Serialized as | Deserialized from |
|---|---|---|
int / float |
native number | anything int() / float() accepts |
str |
native string | passed through unchanged |
bool |
native boolean | native JSON boolean only — anything else is an Err |
datetime |
value.isoformat() |
datetime.fromisoformat(value) |
UUID |
str(value) |
UUID(value) |
Enum |
value.serialize() |
member name, then member value, or value.deserialize() |
nested NamedTuple |
nested JSON object | nested JSON object |
list[NamedTuple] |
list of JSON objects | list of JSON objects |
Optional[T] / T | None |
null or the encoded T |
null or a T payload |
Union[A, B] |
whatever the value is | each arm is tried until one succeeds |
| anything else | passed through unchanged | passed through unchanged |
Types not listed above are passed through untouched in both directions. That
keeps the library out of your way for values that are already JSON-native, but
it also means a str field is not validated to be a string — only fields
with a conversion listed above are checked.
Models must be typing.NamedTuple, because the annotations are the schema.
An unannotated collections.namedtuple still serializes — that reads runtime
values — but it cannot be deserialized, since there are no annotations to
drive the conversion, and deserialize returns an Err saying so. Annotate
the model to make it work in both directions.
bool is deliberately strict: only a native JSON boolean is accepted. Truthy
strings and numbers are rejected rather than coerced, so "true", "yes", and
1 each produce an Err instead of silently becoming True.
Both union spellings are equivalent — Optional[T], Union[T, None], and the
PEP 604 T | None are handled identically on every supported Python version.
serialize(model, skip_nones=False) -> JSONObject walks a NamedTuple
instance and returns a plain dict ready for json.dumps.
import json
from datetime import datetime
from enum import Enum
from typing import NamedTuple, Optional
from uuid import UUID
from serdechko import serialize, serialize_enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
def serialize(self) -> str:
return serialize_enum(self)
class Tag(NamedTuple):
name: str
class Post(NamedTuple):
id: UUID
title: str
created: datetime
status: Status
author: User
tags: list[Tag]
summary: Optional[str] = None
post = Post(
id=UUID("7c9e6679-7425-40de-944b-e07fc1f90ae7"),
title="Hello",
created=datetime.fromisoformat("2026-01-01T12:00:00+00:00"),
status=Status.OPEN,
author=user,
tags=[Tag("python"), Tag("serde")],
)
assert serialize(post) == {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"title": "Hello",
"created": "2026-01-01T12:00:00+00:00",
"status": "open",
"author": {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
"created": "2026-01-01T12:00:00+00:00",
"email": None,
},
"tags": [{"name": "python"}, {"name": "serde"}],
"summary": None,
}
# the result is a plain dict, so it goes straight into json.dumps
body = json.dumps(serialize(post))Nested models, lists of models, enums, datetime, and UUID are all encoded
in one pass — there is nothing to register and no encoder to pass to
json.dumps.
A field value that implements a serialize() method controls its own
encoding. This is the extension point for enums and for your own types:
class Money:
def __init__(self, amount: int, currency: str) -> None:
self.amount = amount
self.currency = currency
def serialize(self) -> str:
return f"{self.amount} {self.currency}"
class Invoice(NamedTuple):
total: Money
assert serialize(Invoice(total=Money(10, "EUR"))) == {"total": "10 EUR"}Field values are dispatched in this order: lists are mapped element-wise,
NamedTuple values are recursed into, and everything else goes through
serialize() / datetime / UUID handling. A NamedTuple is therefore
always expanded into a nested object — defining serialize() on one will not
override that.
Pass skip_nones=True to omit fields whose serialized value is None:
assert serialize(user, skip_nones=True) == {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
"created": "2026-01-01T12:00:00+00:00",
}To make that the default for a model, set the SKIP_SERIALIZING_IF_NONE class
attribute. It applies to the model and everything nested inside it:
class Account(NamedTuple):
SKIP_SERIALIZING_IF_NONE = True
id: UUID
name: str
email: Optional[str] = None
assert serialize(Account(id=user.id, name="Ferris")) == {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
}The explicit argument wins: skip_nones=True skips None fields even when the
class attribute is absent or False. Note that the reverse is not available —
once a model sets the attribute to True, passing skip_nones=False will not
force None fields back into the output.
deserialize(cls, data) -> Result[T, Exception] builds a model from a JSON
object, converting each value according to the field's annotation:
import json
from serdechko import deserialize
body = """
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"title": "Hello",
"created": "2026-01-01T12:00:00+00:00",
"status": "open",
"author": {
"id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"name": "Ferris",
"created": "2026-01-01T12:00:00+00:00"
},
"tags": [{"name": "python"}, {"name": "serde"}]
}
"""
post = deserialize(Post, json.loads(body)).unwrap()
assert post.id == UUID("7c9e6679-7425-40de-944b-e07fc1f90ae7")
assert post.created == datetime.fromisoformat("2026-01-01T12:00:00+00:00")
assert post.status is Status.OPEN # decoded by member value
assert post.author.name == "Ferris" # nested model
assert post.tags == [Tag("python"), Tag("serde")] # list of models
assert post.summary is None # key absent, default appliedIt never raises for modeled failures — inspect the Result instead:
from rusty import Err, Ok
from serdechko import deserialize
match deserialize(Post, json.loads(body)):
case Ok(post):
print(post.title)
case Err(error):
print(f"bad payload: {error}")Because it returns a Result, decoding composes with the rest of the rusty
API:
name = (
deserialize(User, payload)
.map(lambda user: user.name.upper())
.unwrap_or("UNKNOWN")
)Use .unwrap() at a boundary where you would rather have an exception — it
re-raises whichever exception the Err carries, which is a SerdeError for
structural problems and the conversion's own exception otherwise. See
Error Handling.
Serializing and deserializing are inverses, so a model survives a full round trip:
assert deserialize(Post, serialize(post)).unwrap() == postGive an Enum a serialize() method so it knows how to encode itself.
serialize_enum provides the common lowercase-value behaviour:
from enum import Enum
from serdechko import serialize_enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
def serialize(self) -> str:
return serialize_enum(self)
assert Status.OPEN.serialize() == "open"Decoding needs no setup. deserialize_enum matches a string against the member
name first and the member value second, both case-insensitively:
from rusty import Ok
from serdechko import deserialize_enum
assert deserialize_enum(Status, "OPEN") == Ok(Status.OPEN) # by name
assert deserialize_enum(Status, "closed") == Ok(Status.CLOSED) # by value
assert deserialize_enum(Status, "unknown").is_err()An enum that needs different rules can define its own deserialize() as a
classmethod or staticmethod; serdechko calls it instead, converting any exception
it raises into an Err:
class LenientStatus(Enum):
OPEN = "open"
CLOSED = "closed"
@classmethod
def deserialize(cls, value: str) -> "LenientStatus":
if value.lower() in ("open", "reopened", "new"):
return cls.OPEN
return cls.CLOSED
class Ticket(NamedTuple):
status: LenientStatus
assert deserialize(Ticket, {"status": "reopened"}).unwrap().status is (
LenientStatus.OPEN
)Nested NamedTuple fields and list[NamedTuple] fields recurse automatically,
in both directions and to any depth — as Post.author and Post.tags above
already showed. Nesting composes without limit:
class Leaf(NamedTuple):
value: int
class Branch(NamedTuple):
leaves: list[Leaf]
class Tree(NamedTuple):
branches: list[Branch]
tree = deserialize(
Tree, {"branches": [{"leaves": [{"value": 1}, {"value": 2}]}]}
).unwrap()
assert tree.branches[0].leaves[1].value == 2
assert serialize(tree) == {"branches": [{"leaves": [{"value": 1}, {"value": 2}]}]}An error anywhere in the tree short-circuits the whole decode and is returned as-is, so a bad value five levels down surfaces at the top-level call.
A field annotated as a list must receive a JSON array of objects — anything
else is an Err. Optional[list[NamedTuple]] additionally accepts null,
exactly like any other Optional[T].
A field is optional to the payload when it declares a default value, and
optional in value when its annotation includes None. The two are
independent:
class Config(NamedTuple):
host: str # key required, value must be present
tls: Optional[bool] # key required, but value may be null
port: int = 8080 # key may be omitted, falls back to 8080
proxy: Optional[str] = None # key may be omitted, value may be nullNote that Python itself requires every field without a default to come before
the ones that have them, so tls sits above port here.
Omitting a key with no default is an error:
assert deserialize(Config, {"port": 80}).is_err() # missing 'host' and 'tls'Passing null for a field whose annotation does not admit None is rejected
by the types that have a real conversion — int, float, datetime, UUID,
and Enum. Pass-through types such as str accept it, per the caveat in
Supported Types.
A union is resolved by trying each arm in the order written and keeping the first that succeeds, so put the most specific type first:
from typing import Union
class Row(NamedTuple):
value: Union[int, str]
assert deserialize(Row, {"value": "42"}).unwrap().value == 42 # int wins
assert deserialize(Row, {"value": "abc"}).unwrap().value == "abc" # falls backIf every arm fails, the last arm's error is returned. Keep in mind that
pass-through types such as str accept any value, so an arm of that kind
placed first will always match.
Structural problems — a missing field, a value of the wrong shape, a payload
that isn't an object — are reported as Err(SerdeError(...)) with a message
naming the offending field:
from serdechko import SerdeError, deserialize
result = deserialize(User, {"name": "Ferris"})
assert result.is_err()
try:
result.unwrap()
except SerdeError as error:
print(error) # Missing required field: 'id'Value conversions keep the exception the conversion itself raised, so a bad
int or datetime comes back as Err(ValueError(...)) rather than being
flattened into a SerdeError:
class Port(NamedTuple):
number: int
error = deserialize(Port, {"number": "http"}).err().unwrap()
assert isinstance(error, ValueError)The same applies to a custom deserialize() on an enum: whatever it raises is
wrapped in Err as-is, keeping its original type. Catch Exception if you
want to handle every failure uniformly.
deserialize reports the first problem it encounters and stops.
deserialize never raises. A non-object payload, a target that isn't a
NamedTuple, and a model whose annotations cannot be resolved all come back as
Err:
assert deserialize(User, ["not", "an", "object"]).is_err()
assert deserialize(int, {}).is_err()That last case is easy to hit by accident: deserialize resolves annotations
with typing.get_type_hints, which looks names up in the module where the
model was defined. A model declared inside a function therefore cannot resolve
a reference to itself, and you get an Err naming the class rather than a
NameError:
def build_model():
class Node(NamedTuple):
value: int
nxt: Optional["Node"] = None # 'Node' is not visible at module level
return Node
error = deserialize(build_model(), {"value": 1}).err().unwrap()
assert "Cannot resolve type hints for 'Node'" in str(error)Define recursive models at module level to avoid it.
serialize has no Result to return, so it raises SerdeError when handed
something that isn't a NamedTuple instance:
import pytest
with pytest.raises(SerdeError):
serialize({"already": "a dict"})from serdechko import (
JSONObject,
JSONResponse,
JSONValue,
SKIP_SERIALIZING_IF_NONE,
SerdeError,
deserialize,
deserialize_enum,
serialize,
serialize_enum,
)| Name | Description |
|---|---|
serialize(model, skip_nones=False) |
Encode a NamedTuple into a JSON object; raises SerdeError on anything else. |
deserialize(cls, data) |
Decode a JSON object into Result[cls, Exception]; never raises. |
serialize_enum(member) |
Encode an Enum member as its lowercased value. |
deserialize_enum(cls, value) |
Decode a string into Result[Enum, Exception]. |
SerdeError |
Error raised or carried for structural failures; see Error Handling. |
SKIP_SERIALIZING_IF_NONE |
Name of the opt-in class attribute for skipping None. |
JSONValue / JSONObject / JSONResponse |
Type aliases for JSON-shaped data. |
The project uses pytest. Install the development dependencies and run the
test suite from the repository root:
python -m pip install -e ".[dev]"
python -m pytestTo measure coverage, add --cov. Branch coverage and the failure threshold are
configured in pyproject.toml, so no extra flags are needed:
python -m pytest --covCI measures coverage on every supported Python version and fails the build if it falls below 90%.
MIT License. See LICENSE.
