Serde Serialization/Deserialization Framework
Jungle’s serde is a C++26 reflection-based serialization/deserialization framework. It does not rely on macros or external code generation — all type information is obtained at compile time through reflection.
Interfaces, Behavior, and Constraints
Core Concepts
The framework decouples “how data is represented” from “what the data is”:
- Target: Receives type-safe data write calls and produces some output format
- Source: Reads from some input format and writes into existing variables
- Customizer: Transforms the value of a specific field during serialization/deserialization
Type Dispatch Order
The serialization and deserialization free functions perform compile-time dispatch on types in the following order:
boolstd::integralstd::floating_pointenumstd::optional<T>std::ranges::range(container/range types)class/struct
If a type does not match any of the above categories, a compile-time static_assert error is raised.
Serialization
Free Functions
// Write into an existing target
template<SerializeTargetImpl Target, typename T>
void serialize(const T &value, Target &target);
// Create a target and return the result
template<SerializeTargetImpl Target, typename T>
typename Target::target_type serialize(T &&value);
serialize(value, target) writes value into target. The convenience overload serialize<Target>(value) default-constructs Target, calls the above function, and returns the final product via target.deliver_result().
SerializeTarget Base Class
All Targets must inherit from SerializeTarget<Target> (CRTP) and satisfy the SerializeTargetImpl concept:
- Define
target_type(the type of the final product) - Implement
deliver_result() -> target_type - Implement
spawn_subtarget() -> Target(creates a child target for nested structures) - Implement
serialize_*methods for each type (called by the base class via CRTP)
Template methods provided by the SerializeTarget base class and their requires constraints:
| Base Class Method | Called Derived Class Methods |
|---|---|
serialize_bool(const bool &) | serialize_bool(const bool &) -> void |
serialize_integral(const I &) | serialize_integral(const I &) -> void |
serialize_floating_point(const F &) | serialize_floating_point(const F &) -> void |
serialize_enum(const T &) | serialize_enum(const T &) -> void |
serialize_optional(const optional<T> &) | serialize_optional_nonnull() -> void, serialize_optional_nullopt() -> void |
serialize_range(const R &) | serialize_range_head() -> void, serialize_range_element_end() -> void, serialize_range_tail(usize) -> void |
serialize_class_object(const T &) | serialize_class_head(string_view) -> void, serialize_class_field(string_view) -> void, serialize_class_field_end() -> void, serialize_class_tail(string_view) -> void |
Class Serialization Flow
For class types, the framework uses reflection to iterate over all non-static data members:
- Without
[[=customized]]annotation: all non-static data members are serialized - With
[[=customized]]annotation: only members marked with[[=field]]participate - For members annotated with
[[=customize<C>]], the customizerC’sserializemethod takes over the field’s output
Reflection uses std::meta::access_context::unchecked() to access private members.
Deserialization
Free Functions
// Construct a new value from payload (T must be default_constructible)
// Returns the value on success, unexpected(Source::error_type) on failure
template<typename T, DeserializeSourceImpl Source>
requires std::is_default_constructible_v<T>
std::expected<T, typename Source::error_type>
deserialize(const typename Source::source_type &source_payload);
// Write from payload into an existing variable
template<typename T, DeserializeSourceImpl Source>
[[nodiscard]] std::expected<void, typename Source::error_type>
deserialize(const typename Source::source_type &source_payload, T &value);
// Operate directly on a source, writing into an existing variable
template<typename T, DeserializeSourceImpl Source>
[[nodiscard]] std::expected<void, typename Source::error_type>
deserialize(Source &source, T &value);
All deserialization operations indicate success/failure via std::expected: overloads that write into an existing variable return expected<void, Source::error_type>, and the overload that constructs a new value returns expected<T, Source::error_type>. Errors propagate from deserialize_* up to these free functions. The caller is responsible for checking the return value.
DeserializeSource Base Class
All Sources must inherit from DeserializeSource<Source> (CRTP) and satisfy the DeserializeSourceImpl concept:
- Define
source_type(the raw input type) - Define
error_type(the error type reported on deserialization failure) - Implement
provide_source(const source_type &) -> void - Implement
spawn_subsource() -> Source - Implement
deserialize_*methods for each type; all methods returnstd::expected<void, error_type>
Template methods provided by the DeserializeSource base class and their requires constraints (E stands for Source::error_type):
| Base Class Method | Called Derived Class Methods |
|---|---|
deserialize_bool(bool &) -> expected<void, E> | deserialize_bool(bool &) -> expected<void, E> |
deserialize_integral(I &) -> expected<void, E> | deserialize_integral(I &) -> expected<void, E> |
deserialize_floating_point(F &) -> expected<void, E> | deserialize_floating_point(F &) -> expected<void, E> |
deserialize_enum(T &) -> expected<void, E> | deserialize_enum(T &) -> expected<void, E> |
deserialize_optional(OptionalT &) -> expected<void, E> | deserialize_optional_nonnull() -> expected<void, E>, deserialize_optional_nullopt() -> expected<void, E> |
deserialize_range(R &) -> expected<void, E> | deserialize_range_head() -> expected<void, E>, deserialize_range_has_element() -> expected<void, E>, deserialize_range_element_end() -> expected<void, E>, deserialize_range_tail() -> expected<void, E> |
deserialize_class_object(T &) -> expected<void, E> | deserialize_class_head() -> expected<void, E>, deserialize_class_field() -> expected<void, E>, deserialize_class_field_end() -> expected<void, E>, deserialize_class_tail() -> expected<void, E> |
Unlike serialization, all deserialization structural methods (
head,field,element_end, etc.) returnexpected<void, E>rather thanvoid. When an illegal format is encountered, they can returnunexpectedto allow the upper layer to roll back or report an error, rather than terminating the process via panic.
deserialize_optional_nonnull/deserialize_optional_nulloptanddeserialize_range_has_elementuse success to mean “this case matched / more elements remain”, andunexpectedto mean “this case did not match / no more elements”. The base class propagates the latter error when both optional paths fail; for ranges, a failedhas_elementends the loop and parsing continues at the tail.
Range Deserialization Flow
if (auto r = deserialize_range_head(); !r) return r; → abort and propagate on failure
while deserialize_range_has_element() succeeds:
spawn_subsource() → create child source
if (auto r = deserialize(subsource, elem); !r) return r; → recursively deserialize element
if (auto r = deserialize_range_element_end(); !r) return r;
if (auto r = deserialize_range_tail(); !r) return r;
Class Deserialization Flow
Symmetric with serialization, but deserialize_class_head() and deserialize_class_field() only return expected<void, E> (success/failure), not the parsed type name/field name — these strings serve only as validation in deserialization, with failure handling unified by the base class control flow.
The customizer receives a member reference and a child source, writes into the member in place, and returns expected; the base class checks that result and propagates it:
if (auto r = customizer_instance.deserialize(value.[:m:], subsource); !r) {
return r;
}
Annotations
| Annotation | Purpose |
|---|---|
[[=customized]] | Marks a class for customized serialization — only [[=field]] members participate |
[[=field]] | Marks a member to participate in serialization (only meaningful in [[=customized]] classes) |
[[=customize<C>]] | Specifies a field-level customizer C; the field’s serialization/deserialization is handled by C |
Annotations may be combined:
struct [[= customized]] S {
[[= field]] int normal = 1;
[[= field]] [[= customize<PlusThousand>]] int boosted = 100;
int skipped = 999; // no [[=field]], excluded from serialization
};
Customizer Concept
template<template<typename> typename Custr>
concept Customizer =
std::is_default_constructible_v<Custr<int>> &&
requires(Custr<int> c, int value, detail::TraitTargetSource &target) {
{ c.serialize(value, target) } -> std::same_as<void>;
{ c.deserialize(value, target) }
-> std::same_as<std::expected<void, typename detail::TraitTargetSource::error_type>>;
};
A customizer is a single-parameter template: template<typename T> struct MyCustomizer { ... }. The framework instantiates MyCustomizer<FieldType> for each field that uses the customizer.
serialize(const T &value, auto &target): writesvaluetotargetafter transformation, returnsvoiddeserialize(T &value, auto &source): reads the raw value fromsourceintovalueand writes the transformed value in place, returningstd::expected<void, typename Source::error_type>
deserialize must propagate errors from the deserialize_* calls it delegates to; the framework checks that return value and continues propagating it.
Implementing Source/Target and Customizer
Implementing TextTarget
The following is a Target implementation that serializes data into a human-readable text format (found in the unit test directory; for reference only):
class TextTarget : public SerializeTarget<TextTarget> {
public:
using target_type = ustr;
TextTarget() = default;
target_type deliver_result() { return std::move(m_result); }
// spawn_subtarget lets nested structures share the same output buffer
TextTarget spawn_subtarget() { return TextTarget{m_result}; }
void serialize_bool(const bool &value) {
m_result.append(value ? "true" : "false");
}
template<std::integral I>
void serialize_integral(const I &value) {
m_result.append(ustr::format("{}", value));
}
template<std::floating_point F>
void serialize_floating_point(const F &value) {
m_result.append(ustr::format("{}", value));
}
template<concepts::is_enum E>
void serialize_enum(const E &value) {
m_result.append(debug(value));
}
void serialize_optional_nonnull() { m_result.append("optional##"); }
void serialize_optional_nullopt() { m_result.append("optional##nullopt"); }
void serialize_range_head() { m_result.append("["); }
void serialize_range_element_end() { m_result.append(","); }
void serialize_range_tail(usize) { m_result.append("]"); }
void serialize_class_head(std::string_view ident) {
m_result.append(ustr::format("{}{{", ident));
}
void serialize_class_field(std::string_view ident) {
m_result.append(ustr::format("{}:", ident));
}
void serialize_class_field_end() { m_result.append(","); }
void serialize_class_tail(std::string_view) { m_result.append("}"); }
private:
TextTarget(ustr &external) : m_storage{std::nullopt}, m_result{external} {}
std::optional<ustr> m_storage{ustr{}};
ustr &m_result{*m_storage};
};
static_assert(SerializeTargetImpl<TextTarget>);
Key design points:
spawn_subtarget()shares the underlying buffer by reference; child target output is appended to the same string- Uses
ustr::formatto format numerics,debug()to format enums - The output format of each
serialize_*method must be consistent with the correspondingTextSourcedeserialize_*parsing logic
Implementing TextSource
class TextSource : public DeserializeSource<TextSource> {
public:
using source_type = ustr;
enum class error_type { mismatch };
TextSource() = default;
void provide_source(const source_type &source) {
m_source = source.view();
*m_cursor = 0;
}
TextSource spawn_subsource() { return TextSource{m_source, m_cursor}; }
std::expected<void, error_type> deserialize_bool(bool &value) { /* parse "true"/"false" */ }
template<std::integral I>
std::expected<void, error_type> deserialize_integral(I &value) { /* from_chars parse */ }
template<std::floating_point F>
std::expected<void, error_type> deserialize_floating_point(F &value) { /* from_chars parse */ }
template<concepts::is_enum E>
std::expected<void, error_type> deserialize_enum(E &value) { /* match enum item by name */ }
std::expected<void, error_type> deserialize_optional_nonnull() { /* consume "optional##"; succeed if not "nullopt" */ }
std::expected<void, error_type> deserialize_optional_nullopt() { /* consume "nullopt" */ }
std::expected<void, error_type> deserialize_range_head() { /* consume "[" */ }
std::expected<void, error_type> deserialize_range_has_element() { /* check if next char is "]" */ }
std::expected<void, error_type> deserialize_range_element_end() { /* consume "," */ }
std::expected<void, error_type> deserialize_range_tail() { /* consume "]" */ }
std::expected<void, error_type> deserialize_class_head() { /* consume "TypeName{" */ }
std::expected<void, error_type> deserialize_class_field() { /* consume "fieldName:" */ }
std::expected<void, error_type> deserialize_class_field_end() { /* consume "," */ }
std::expected<void, error_type> deserialize_class_tail() { /* consume "}" */ }
private:
TextSource(std::string_view source, usize *cursor)
: m_source{source}, m_cursor{cursor} {}
std::string_view m_source;
usize m_owned_cursor{0};
usize *m_cursor{&m_owned_cursor};
};
static_assert(DeserializeSourceImpl<TextSource>);
Key design points:
- All methods return
std::expected<void, error_type>: return{}on successful parse,unexpectedon format mismatch (no panic) spawn_subsource()shares a cursor: child source reads automatically advance the parent source’s positiondeserialize_optional_nonnull()consumes the"optional##"prefix then only peeks (does not consume)"nullopt"; if it is indeed nullopt, returnsunexpectedanddeserialize_optional_nullopt()consumes itdeserialize_class_head()anddeserialize_class_field()only returnexpected<void, error_type>, not the parsed type name/field name — these values are unused on the deserialization path
Implementing a Customizer
The following customizer adds 1000 to the value during serialization and subtracts 1000 during deserialization:
template<typename T>
struct PlusThousand {
void serialize(const T &value, auto &target) const {
target.serialize_integral(value + 1000);
}
auto deserialize(T &value, auto &source) const
-> std::expected<void, typename std::remove_cvref_t<decltype(source)>::error_type> {
if (auto r = source.template deserialize_integral<T>(value); !r) {
return r;
}
value -= 1000;
return {};
}
};
static_assert(Customizer<PlusThousand>);
Usage:
struct S {
[[= customize<PlusThousand>]] int score = 42;
};
A customizer must satisfy:
- It is a single-parameter template (
template<typename> struct) serializereceivesconst T &and a target reference, returnsvoiddeserializereceivesT &and a source reference, returnsstd::expected<void, typename Source::error_type>, and updates the value in placedeserializeinternally calls the source’s value-writing method (e.g.,deserialize_integral) to obtain the raw value, and propagates that error unchanged
Usage Examples
// Serialization
ustr text = serialize<TextTarget>(my_object);
// Deserialization (construct new value, returns expected<T, error_type>)
auto result = deserialize<MyStruct, TextSource>(text);
if (result.has_value()) {
// use *result
}
// Deserialization (write into existing variable, returns expected<void, error_type>)
MyStruct obj;
auto ok = deserialize<MyStruct, TextSource>(text, obj);
// Deserialization (operate directly on source)
TextSource src;
src.provide_source(text);
auto ok = deserialize(src, obj);