Reflection
ll/api/reflection/ Β· Common
Overview
The reflection module maps C++ structs to and from JSON / NBT without hand-writing a reader and a writer for every type. Typical uses:
- mod configuration files (Config is built on top of it)
- the mod manifest,
manifest.json - persistence such as world or player data
- custom NBT data (
CompoundTagVariant) - network packets and other JSON-like / NBT-like structures
It is not JSON specific: nlohmann::json is only the most common target, and any type exposing queries such as is_object / is_array / is_string together with the object() / array() factories can be handled by the same code.
How to read this page:
- Store a plain struct as JSON β read the Quick Start.
- Give a type a string form (a version number, an id, a UUID) β read Specializing a Simple Type.
- Control required fields, write advanced specializations, or debug a specialization that appears to do nothing β read Reflection (Advanced).
Headers
| Header | Description |
|---|---|
ll/api/reflection/Reflection.h |
Core reflection utilities, member iteration, string conversion |
ll/api/reflection/Serialization.h |
Generic serialization |
ll/api/reflection/Deserialization.h |
Generic deserialization |
ll/api/reflection/Serializer.h |
Extension point for custom serializers |
ll/api/reflection/Dispatcher.h |
Dispatcher support |
ll/api/reflection/ReflectionError.h |
Common reflection errors |
ll/api/reflection/TypeName.h |
Compile-time type names and name trimming |
Reflectable Types
An aggregate needs no specialization at all. That means: no user declared constructors, no private or protected non-static data members, and no private or protected bases. Typical examples:
- Plain structs with public data members
- Nested aggregate structs
- Containers and optionals inside aggregates
The default mapping is member-name to field-name, applied recursively. How each kind of type maps:
| C++ type | Output | Notes |
|---|---|---|
arithmetic int / float / double β¦ |
number | bool becomes a boolean; numeric conversion is strict, see below |
std::string and other string types |
string | |
std::string_view |
string | only deserializes from a safely borrowable lvalue source, see the advanced page |
| enums | string (the enum name) | falls back to the underlying integer when no name is available |
std::optional<T> |
whatever T maps to |
an empty optional as an object member omits the whole key instead of writing null |
std::variant<...> |
whatever the current alternative maps to | |
std::pair / std::tuple / std::array |
array | the element count must match exactly on deserialization |
std::vector / std::list / std::set β¦ |
array | |
std::map / std::unordered_map β¦ |
object | the key type has to be string-convertible |
| nested aggregates | object | the same rules apply recursively |
Two things that regularly surprise people:
- Default deserialization requires every non-optional member to be present. Default member initializers (
int level = 0;) are not used as a fallback β a missing field reports a missing-field error β and extra keys in the input are ignored. - Numeric conversion is strict: out-of-range values (
300into anint8), fractional values written into an integer type, and negative values written into an unsigned type are all reported instead of being truncated or wrapped. Booleans written into an arithmetic type become 0 / 1.
Types that are not a good fit (private members, user declared constructors, custom construction logic, a required string or discriminated representation, ...) need a Serializer<T> specialization, described below.
Quick Start: Basic Aggregate Types
Three steps.
1. Define the struct
Public data members are all it takes:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
2. Serialize
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
The result is an object keyed by member name:
| JSON | |
|---|---|
1 2 3 4 5 | |
nlohmann::json sorts keys, so this order differs from the declaration order; switch to nlohmann::ordered_json to keep the declaration order (the config system does exactly that).
3. Deserialize
| C++ | |
|---|---|
1 2 3 | |
deserialize_to<T> reads a fresh T; when you need to fill an object that already exists (a config object owned by the caller, for example), call deserialize(out, json) instead.
The default rule is member-name to field-name, applied recursively; the table above shows how each type maps. It is strict: a missing non-optional member reports a missing-field error, while extra keys in the input are ignored.
Core API
Serialization
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
J is the target type: nlohmann::json, CompoundTagVariant, or a type you support through Serializer<T> yourself.
Deserialization
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
All public entry points return ll::Expected, used exactly like the rest of the API:
| C++ | |
|---|---|
1 2 3 4 5 | |
Most operations come in two flavors; pick by whether you already have a target object:
| What you want | Which entry point |
|---|---|
| Convert a value into a fresh target object | serialize<J>(value) / deserialize_to<T>(input) |
| Write into an object that already exists | serialize_to<J>(out, value) / deserialize(out, input) |
Every entry point also has a variant without a key formatter, which uses the default one (keys are passed through unchanged).
Specializing a Simple Type
Some types are not naturally "a set of members": a UUID, a version number, a resource id. Their natural form is a string. Giving them one takes a Serializer<T> specialization with two layers β the first makes the type usable as a key, the second makes it render as a string inside objects.
Take a strongly typed id:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
What changes:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
Notes:
- Write both layers. With only
to_string/from_stringthe type works as a key, but as an ordinary member it is still expanded member by member (for an aggregate) or reported as unsupported (for a non-aggregate). - Match the signatures exactly and never return
void, or the specialization is silently ignored β the full checklist is in the advanced page. - This is exactly what LL does for
mce::UUIDandll::data::Version; they pair an in-placeserialize(t, j)that writes the string into the target with a valuedeserialize, and the framework accepts both spellings. - To change only how a key is spelled (uppercase UUID keys, say) without making the type a string, use a key formatter, below.
Optionals and Required Fields
Default deserialization is strict, and there are only three rules:
| Member declaration | When the key is missing from the input |
|---|---|
std::optional<T> m{}; |
succeeds, m becomes std::nullopt |
a plain member T m{}; |
reports missing required field "m" when deserializing |
Writing mirrors this: an empty std::optional<T> member omits the whole key instead of writing null. "Absent key" and null are therefore indistinguishable in the output, and consumers have to treat the field as optional.
When you need "keep the current value if the field is absent" or "use a fallback value", use default_member / default_field (see the member helpers section of the advanced page), or simply declare the member as std::optional<T>.
Key Formatters
A key formatter controls how member names, enum names and keys are converted during serialization and deserialization β uppercasing everything, for instance:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
A formatter can also customize only one key type (uppercase UUID keys while parsing stays unchanged); that usage lives in the advanced page.
Error Model
Every public entry point returns ll::Expected, and failures carry a readable path:
| Text Only | |
|---|---|
1 2 | |
Common failures:
| Message fragment | Meaning |
|---|---|
field must be a string / field must be an object / field must be an array |
type mismatch |
missing required field "x" when deserializing |
a required field is absent |
field must be a number or boolean |
a number or boolean was expected |
number 300 is out of range for signed char |
the number does not fit the target type |
number 1.5 is not an integer |
a fractional value was written into an integer type |
invalid enum value: x |
the enum value is unknown |
invalid key: x |
an associative key could not be parsed |
could not cast target object (...) to any type in the variant |
no alternative of the variant matched |
At compile time there are two main diagnostics, both naming the offending type:
cannot serialize this type: .../cannot deserialize this type: ...β the type has no usable path, so it needs aSerializer<T>specialization. The full table of compile-time failures and the way to track them down are in the advanced page.
Numeric conversion is strict: out-of-range values, fractional values, and negative values written into unsigned targets are reported rather than truncated or wrapped, identically for the JSON and NBT backends; booleans written into an arithmetic type become 0 / 1.
Common Mistakes
- Only implementing
to_string/from_stringand expecting the string form inside objects β the second layer is needed too. - A specialization signature that differs from the documented one (an extra
const, a missingtypename F) β it is silently ignored, and on an aggregate there is not even a diagnostic. - Returning
voidfrom a specialization β unsupported. - Assuming default member initializers fill in missing fields β they do not; a missing non-optional member reports an error.
- Assuming an empty
std::optionalbecomesnullβ as an object member the whole key is omitted. - Relying on a silent numeric conversion β
1.5intoint,300intoint8, or-1into an unsigned type are all reported.
Next Steps
The advanced page covers:
- the full signature table, the return-type rules and the "specialization not being used" checklist;
- member helpers (
member/field/required_field/default_fieldfor conditional requirements and fallback values); - complex examples taken from the test suite: a discriminator deciding required fields, flattened output, types that cannot be default constructed, discriminated variants, ABI wrappers, and one type rendering differently for JSON and NBT;
- plugging another data structure (a script engine object,
Json::Value, your ownValuetype) into the framework: custom target types; - the dispatch priorities, how
std::variantmatches,Dispatcher,std::string_viewlifetime, and the compile-time error table.
Related
- Reflection (Advanced)
- Config: the config system, the main consumer of reflection
- Base: the base utilities reflection builds on (concepts, containers,
Expected)