Declarative JSON Dispatch in Modern C++
Source: Dev.to
Working with JSON in C++
“Working with JSON in C++ is no longer a matter of parsing bytes. Mature libraries such as nlohmann/json already provide a robust, dynamic representation of JSON values. The real challenge emerges one layer above parsing: how to structure the control flow that interprets those values.”
For tiny examples a linear chain of if / else if checks is often sufficient. As soon as the logic becomes recursive or semantically nuanced—e.g., distinguishing empty arrays from non‑empty ones, or recognizing objects with specific structural properties—the code starts to accumulate branching noise. Classification, branching, and behavior become tightly coupled, making the control flow hard to reason about locally.
Goal
“Rather than asking how to optimise conditional logic, ask whether the dispatch itself can be described declaratively, while still operating on an existing, dynamic JSON model.”
The full implementation discussed here lives at
🔗
Only the essential fragments are shown below.
Why a Declarative Dispatch?
- Dynamic JSON ≈ Sum Type – Although
nlohmann::jsonis dynamically typed, its runtime behaviour is effectively that of a sum type: a value is exactly one of the mutually exclusive alternatives null, boolean, number, string, array, or object. - Current Pain Points
- Conditional chains scale poorly as cases accumulate.
- Visitor‑style designs add indirection and boilerplate without solving the semantic coupling between matching and behaviour.
- Variant‑based dispatch forces a translation of JSON into a closed algebraic data type, which is often impractical.
What we need is a way to describe dispatch logic directly, as a composition of semantic cases, without replacing the underlying data model.
The Match‑Pipeline Approach
void parse_json(const json& j, int depth = 0) {
match(j)
.when(bind()[is_type(json::value_t::null)] >> print_null(depth))
.when(bind()[is_type(json::value_t::boolean)] >> print_bool(depth))
.when(bind()[is_int] >> print_int(depth))
.when(bind()[is_uint] >> print_uint(depth))
.when(bind()[is_float] >> print_float(depth))
.when(bind()[is_type(json::value_t::string)] >> print_string(depth))
.when(bind()[is_type(json::value_t::array)] >> handle_array(depth))
.when(bind()[has_field("name")] >> handle_named_object(depth))
.otherwise([=] {
indent(depth);
std::cout << "\n";
});
}
What This Gives Us
| Concern | Traditional Code | Declarative Match |
|---|---|---|
| Classification | Interleaved with control flow | Explicit predicates (is_type, has_field, …) |
| Binding | Implicit, scattered | bind() makes it explicit |
| Behaviour | Mixed with matching logic | Handlers (print_null, handle_array, …) are pure actions |
The match expression itself is a declarative description of the dispatch strategy, not an encoded sequence of branches.
Design Constraints
- No Implicit Binding – Everything that needs data must be bound explicitly via
bind(). - Three‑Step Separation
- Matching – Determines whether a case applies.
- Binding – Captures the data that will be handed to the handler.
- Handling – Performs the side‑effects (output, recursion, …).
Guards are evaluated after binding, guaranteeing that predicates operate on concrete values rather than symbolic placeholders. Consequently, handler signatures are stable and predictable: each receives exactly one argument, const json&, regardless of how complex the matching condition is.
Predicates
inline auto is_type(json::value_t t) {
return [=](const json& j) { return j.type() == t; };
}
inline auto has_field(const std::string& key) {
return [=](const json& j) {
return j.is_object() && j.contains(key);
};
}
Within the match expression these predicates serve a semantic role rather than a control‑flow one. A guard refines the meaning of a case; it does not represent an executable branch. This distinction becomes crucial as predicates grow more complex or are reused across multiple cases.
Handlers – Pure Behaviour
inline auto print_null(int depth) {
return [=](auto&&...) {
indent(depth);
std::cout << "null\n";
};
}
The handler’s signature reflects its intent: it responds to a semantic category, not to a specific data shape.
This decoupling lets handlers evolve independently of the matching structure and makes it clear which part of the system is responsible for which decision.
Recursive Traversal
inline auto handle_array(int depth) {
return [=](const json& j) {
indent(depth);
std::cout << "[\n";
for (const auto& elem : j) {
parse_json(elem, depth + 1);
}
indent(depth);
std::cout << "]\n";
};
}
(Additional handlers such as handle_named_object, print_int, print_string, etc., follow the same pattern.)
Summary
- Declarative match pipelines let you describe what to do for each JSON shape, not how to get there.
- By separating matching, binding, and handling, the code stays readable, extensible, and free from tangled control‑flow scaffolding.
- The approach works directly on the dynamic
nlohmann::jsonmodel, avoiding the need to convert JSON into a closed algebraic data type.
Feel free to explore the full sample implementation in the linked repository. Happy dispatching!
Additional Example (excerpt)
inline auto handle_array_verbose(int depth) {
return [=](const json& arr) {
indent(depth);
std::cout << "array [" << arr.size() << "]\n";
for (const auto& v : arr) {
parse_json(v, depth + 1);
}
};
}
The recursion is explicit, local, and unsurprising. There is no hidden control flow and no implicit coupling between traversal and dispatch strategy.
The advantage of this approach is not syntactic novelty. It lies in the discipline it enforces:
- Classification is localized to patterns.
- Semantic constraints are expressed as guards.
- Behavior is confined to handlers.
As new cases are introduced—additional structural distinctions, schema‑like checks, or specialized interpretations—the match expression grows horizontally rather than becoming more deeply nested.
An interesting extension is an extractor pattern: a pattern that validates structure and binds subcomponents directly. In a JSON context, such a pattern could match an object with specific fields and bind those fields as independent values, moving structural decomposition out of handlers and into the pattern layer itself.
The full implementation, including all predicates and handlers, is available at
https://github.com/sentomk/patternia/tree/main/samples/json_dispatch.