…and why JsonFusion doesn't have a native std::variant mode
There are 3 layers of thinking between the moment JSON message bytes come into a parser and the moment application logic makes decisions based on them.
JSON only has six kinds of values:
nullboolnumberstringarrayobject
That's it. No oneOf, no date, no "discriminated union", no "RPC". Just those six.
JsonFusion's core is about this layer only:
"Given a C++ type that looks like JSON, parse/serialize it efficiently."
"Looks like JSON" here means:
- Object →
structwith fields /std::map-like / map streamers - Array →
std::vector<T>,std::array<T, N>, C arrays, array streamers, … - String →
std::string,std::string_view,char[N], etc. - Number → integral / floating scalar types
- Bool →
bool - null →
std::optional<T>,std::unique_ptr<T>
Static schema concepts (ObjectLike, JsonArray, ParsableStringLike, SerializableStringLike, …) are all about answering:
If I ignore annotations, options, validators, etc., does this C++ type represent one of the six JSON value categories?
That's the wire type: the shape of the JSON itself.
Once you have a pure JSON-shaped type, you usually want constraints:
range<0, 100>on a numbermin_length<3>on a stringmin_items<1>on an array- custom predicates via
fn_validator<...>
These are still entirely about layer 1. They don't change what the JSON is, they just filter which values are allowed.
Conceptually:
struct Config {
A<int, range<0, 100>> level;
A<std::string, min_length<3>> name;
};This is still just:
{
"level": 42,
"name": "foo"
}Validators are:
- Attached to the wire type (
int,std::string,std::array<…>, …) - Triggered on parse events (number parsed, array finished, object finished, …)
- Never introduce new JSON categories
In other words, validators say:
"From the universe of possible JSON values with this shape, I only accept this subset."
They don't try to introduce "schema algebra" like oneOf, not, etc. That stuff belongs to a different layer (we'll get there).
Now we leave JSON land and move into your app's world:
- You want
MyDateinstead of"2025-01-01" - You want a reducer over an array instead of storing all elements
- You want some kind of
oneOf/sum type - You might even want no storage at all, just "parse and call a callback", RPC style
This is layer 3: domain model & behavior.
JsonFusion core doesn't know anything about MyDate, or reducers, or sum types. Instead, it gives you three generic mechanisms to bridge layer 1 (wire JSON) to layer 3 (domain):
- Transformers – value ↔ value
- Streamers – JSON array/map → callbacks instead of storage
WireSinkobject – "capture this subtree as raw JSON/CBOR" so you can interpret it later
Let's look at each.
A transformer is a small object that says:
"Treat me as JSON of type
wire_type, but when parsing/serializing, run some custom code."
Minimal core concept:
namespace static_schema {
template<class T>
concept ParseTransformerLike = requires(T t, const typename T::wire_type& w) {
typename T::wire_type; // the JSON-facing type
{ t.transform_from(w) } -> std::convertible_to<bool>;
};
template<class T>
concept SerializeTransformerLike = requires(const T t, typename T::wire_type& w) {
typename T::wire_type;
{ t.transform_to(w) } -> std::convertible_to<bool>;
};
} // namespace static_schemaThe parser logic becomes roughly:
if constexpr (ParseTransformerLike<FieldT>) {
typename FieldT::wire_type tmp;
// Parse JSON into tmp as if tmp were the actual field
if (!Parse(tmp, reader, ctx)) { ... }
// Then hand tmp to the transformer
if (!field.transform_from(tmp)) { ... }
}Example: string → MyDate
struct MyDate {
int year, month, day;
static bool from_string(const std::string& s, MyDate& out);
static bool to_string(const MyDate& d, std::string& out);
};
template<class DateT>
struct DateTransformer {
using wire_type = std::string;
DateT value;
bool transform_from(const std::string& s) {
return DateT::from_string(s, value);
}
bool transform_to(std::string& s) const {
return DateT::to_string(value, s);
}
};
struct Event {
// In JSON: "date": "2024-01-01"
// In C++: Event::date.value is MyDate
A<DateTransformer<MyDate>, key<"date">> date;
};From the parser's point of view:
- It sees a
wire_typeofstd::string - It parses a JSON string into a temporary
std::string - It calls
transform_fromto populate yourMyDate
The core doesn't know or care what MyDate is. It just knows how to drive wire_type.
Streamers say:
"Treat me like an array/map in JSON, but instead of storing elements, call
consume()for each one."
Basic example used in benchmarks:
template <class ValueT>
struct CountingStreamer {
using value_type = ValueT;
std::size_t counter = 0;
void reset() { counter = 0; }
bool consume(const ValueT&) {
++counter;
return true;
}
bool finalize(bool success) { return success; }
};More ergonomic variant with a context-bound lambda:
#include <JsonFusion/generic_streamer.hpp>
struct Stats {
std::size_t totalPoints = 0;
};
struct Point { double x, y, z; };
using PointsArrayConsumer = streamers::LambdaStreamer<
[](Stats* stats, const Point&) {
stats->totalPoints++;
return true;
}
>;To JsonFusion core, this is just "an array of Point", but instead of std::vector<Point> it has a streamer type that gets fed element by element.
Again: the core stays in layer 1; the semantics "count points", "accumulate sum", "send over UART" are all layer 3.
Sometimes you don't want to interpret a subtree immediately at all. You just want:
"Give me this part of JSON as a buffer/string/DOM, I'll decide later."
That's where WireSink comes in:
- JsonFusion walks the JSON as usual
- For the
WireSinkfield, it re-emits the raw JSON into your sink - You end up with a "sub-document" or chunk of JSON you can feed into:
- another parser,
- a schema engine (
oneOf/anyOf/custom logic), - a domain-specific interpreter
This is the natural place to implement schema algebra like "try shape A, or B, or C" without making the core understand oneOf.
Here's the key realization:
JSON itself has no variant.
variant is a schema / domain concept, not a JSON concept.
There are basically two things people mean when they say "variant":
-
Schema-level "oneOf / anyOf"
"The JSON at this location may be shape A or shape B or shape C." -
Domain-level sum type
"In C++ I want astd::variant<A,B,C>because that's how my code thinks."
Neither of those needs the core parser to know about std::variant.
You can build them in layer 3:
- Use
WireSinkto capture the subtree - Try multiple
Parse(...)calls into different candidate wire types - Wrap the chosen one into a
std::variant - Glue it to JsonFusion via a transformer
So instead of:
std::variant<A, B, C> x; // baked into coreYou write:
struct OneOf_ABC {
using wire_type = WireSink<1024>;
std::variant<A,B,C> value;
bool transform_from(const auto& parseFn) {
// Try parsing as A, then B, then C - first success wins
if (parseFn(value.emplace<A>())) return true;
if (parseFn(value.emplace<B>())) return true;
if (parseFn(value.emplace<C>())) return true;
return false; // None matched
}
bool transform_to(const auto& serializeFn) const {
// Serialize whichever variant alternative is active
return std::visit([&](const auto& v) {
return serializeFn(v);
}, value);
}
};This is precisely the kind of thing that belongs in "extras", not in the core:
- It's schema/domain logic ("pick one of multiple shapes")
- It's built entirely from:
- core parser (
Parse) WireSink- transformers (which has special support for WIreSink: automatically configured proper Reader/Writer is created and callable object passed to user code)
- core parser (
The core stays beautifully ignorant.
The three layers look like this:
- Whatever the JSON standard defines: null/bool/number/string/array/object
- JsonFusion's
static_schemaconcepts map C++ types onto those - Parser + Reader operate here, nothing else
- Restrict which JSON values are allowed, but don't change the type
- Still purely about layer 1, just "a valid subset"
- Your
MyDate,std::variant, reducers, RPC, business logic - Implemented via:
- Transformers (wire JSON ↔ domain type)
- Streamers (arrays/maps → callbacks with context)
WireSink(capture raw JSON to handle later or elsewhere)
- All arbitrarily composable
JsonFusion core should never embed domain concepts into layer 1.
It models pure JSON, and opens doors (Transformers/Streamers/WireSink) for everything else.
That's why you can now:
- Express arbitrary JSON shapes directly as C++ types (implicitly attaching some constraints, like the lack of abstract "number" in C++)
- Add constraints with validators
- Build all higher-level behavior (dates, reducers, oneOf-like constructs, RPC) using generic hooks, without ever polluting the core with "special cases"
You can imagine a very reasonable pushback:
"Okay, okay, full schema algebra is complex, but… what if JsonFusion just treated
std::variant<A,B,C>asoneOf [A,B,C]? Most people only need that, right?"
Tempting, but it breaks the nice 3-layer separation and creates more problems than it solves. Concretely:
Given JSON like:
{ "value": ... }and a C++ type:
std::variant<A, B, C> value;a generic "core-level" implementation can't know which arm will match without trying them:
- Try parse as A
- If that fails (or validation fails), try B
- If that fails, try C
- If all fail → error
That is N independent attempts over the same subtree, where N is the number of arms.
But now you've forced that strategy into the core:
- Even if the user knows "99% of the time it's B", the core can't specialize that
- Even if the user wants a totally different resolution strategy ("look at a type field first", "dispatch on shape"), the core's variant logic is in the way
If you do this in layer 3 instead (via transformer + WireSink), you can implement whichever strategy you want:
- First try B, then C, then A
- Or inspect a discriminator and only parse the matching type
- Or do shape-based dispatch
The core stays simple and predictable.
If the core owns std::variant<A,B,C> handling, it has to answer:
"Where do I keep the JSON while I'm trying multiple arms?"
Options, none of them nice:
-
Re-parse from the original input stream N times
→ Terrible for streaming readers, and Reader API is deliberately forward-only -
Copy the JSON subtree into some intermediate structure (buffer, small DOM, etc.)
→ Now you've baked buffering policy into the core:- How big?
- Heap or stack?
- What about embedded targets?
- Who pays for this when they don't use variants?
But if you move this to layer 3:
- You can say: "for this particular oneOf-style transformer, I'll use a
WireSinkstring buffer", or a small DOM, or whatever fits your constraints - Different call sites can choose different strategies
- Core never needs to know about buffering policy at all
The slippery slope looks like this:
-
Start with:
std::variant<A,B,C>
↔ "JSON matches one of these shapes."
-
Then people want:
anyOf(likeoneOfbut multiple shapes may be valid)allOf(intersection of schemas)not(negative constraints)- complex nested combinations
-
Now you're halfway to a JSON Schema engine – but:
- half of the logic is in the parser core,
- the other half ends up bolted on externally,
- semantics get scattered and hard to reason about
And still:
- You'll want custom resolution policies
- You'll want better error reporting ("it failed arm B because field X missing", etc.)
- You'll want per-arm validation and custom messages
All of that clearly belongs in layer 3: a separate "schema algebra & resolution" layer that uses JsonFusion as a fast building block — not in the tight inner parsing loop, which is now scalable from tiny 15KB binary code on µC to high-performance ~1Gb/s-level parser.
Instead of putting std::variant into the core, you get:
- Pure JSON core (layer 1) — only the six JSON types
- Validators (layer 2) — filter allowed values, still purely JSON
- Transformers / Streamers /
WireSink(layer 3 hooks) — everything domain-specific:- dates,
- reducers,
- RPC,
- and yes, your own
oneOf/std::varianthandling
So if someone really wants variant + oneOf, they can build:
struct VariantOneOf {
using wire_type = WireSink<1024>;
std::variant<A,B,C> value;
bool transform_from(const auto& parseFn) {
// custom resolution logic here
}
bool transform_to(const auto& serializeFn) const {
// serialize active arm here
}
};No core pollution, no hardcoded buffering policy, no half-baked schema algebra glued into the parser.
That's the whole story in one line:
JsonFusion models JSON, not your schema.
Everything "schema-ish" – includingstd::variant/oneOf– belongs on top, built from transformers, streamers and sinks, not baked into the engine.
"But wait," you might say, "doesn't this make it really hard to use variants in practice?"
Not at all. In fact, we ship JsonFusion::VariantOneOf ready to use in JsonFusion/variant_transformer.hpp.
The difference? It's implemented as a layer 3 transformer using the exact building blocks described above—not as special-case logic in the parser core. Open that file and you'll see: it's ~50 lines proving that the architecture works. The core stays pure, and you still get convenient std::variant support. Best of both worlds.