DEV Community

sentomk
sentomk

Posted on

Declarative JSON Dispatch in Modern 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 small examples, a linear chain of if / else if checks is often sufficient. As soon as the logic becomes recursive or semantically nuanced—distinguishing empty arrays from non-empty ones, or recognizing objects with specific structural properties—the code begins to accumulate branching noise. Classification, branching, and behavior become tightly coupled, and the resulting control flow is difficult to reason about locally.

This article examines that problem from a different angle. Rather than asking how to optimize conditional logic, it asks whether the dispatch itself can be described declaratively, while still operating on an existing, dynamic JSON model.

The complete implementation discussed here is available at

https://github.com/sentomk/patternia/tree/main/samples/json_dispatch.

Only the essential fragments are shown below.


JSON as a semantic sum type

Although nlohmann::json is dynamically typed, its runtime behavior is effectively that of a sum type. A value is exactly one of several mutually exclusive alternatives: null, boolean, number, string, array, or object. Many real-world JSON consumers already reason about values in this way, even if the code does not make that structure explicit.

Once this perspective is adopted, the desired control flow becomes clearer. Each branch of interpretation should be defined by what kind of value it matches, possibly refined by additional semantic constraints, and finally associated with a concrete action. The difficulty lies not in expressing any individual check, but in expressing the structure of the dispatch itself.

Traditional approaches each fall short in different ways. Conditional chains scale poorly as cases accumulate. Visitor-style designs introduce indirection and boilerplate without addressing the semantic coupling between matching and behavior. Variant-based dispatch requires translating JSON into a closed algebraic data type, which is often impractical or undesirable.

What is missing is a way to describe dispatch logic directly, as a composition of semantic cases, without replacing the underlying data model.


A declarative match pipeline

The approach explored here expresses JSON interpretation as a single, explicit match pipeline. Each case states what it matches and what it does, without embedding that logic in control-flow scaffolding.

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 << "<unknown>\n";
    });
}
Enter fullscreen mode Exit fullscreen mode

This formulation separates three concerns that are usually intertwined: classification, value binding, and behavior. The match expression itself is a declarative description of the dispatch strategy, not an encoded sequence of branches.


Explicit binding as a semantic boundary

One notable design constraint is that nothing binds implicitly. Binding is an explicit operation, introduced bybind(). In this example, every case binds the entire JSON value as a single unit and passes it to the handler.

This constraint is deliberate. It establishes a clear semantic boundary: matching determines whether a case applies, binding determines what data becomes available, and handlers are free to ignore or consume that data as needed. Guards are evaluated only after binding, ensuring that predicates operate on concrete values rather than symbolic placeholders.

As a result, handler signatures are stable and predictable. Each handler in this example receives exactly one argument, const json&, regardless of how complex the matching condition is.


Guards as semantic constraints

Predicates such as is_type and has_field are ordinary functions returning callables:

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

Within the match expression, however, 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. Read declaratively, a case states: bind the subject, then accept this case only if it satisfies this constraint.

This distinction becomes increasingly important as predicates grow more complex or are reused across multiple cases. The match expression remains a description of intent, rather than an encoded decision tree.


Handlers as isolated effects

Handlers are intentionally free of classification logic. They perform output, recursion, or side effects, but they do not decide whether they should run.

For example, the null handler does not even consume the bound value:

inline auto print_null(int depth) {
  return [=](auto&&...) {
    indent(depth);
    std::cout << "null\n";
  };
}
Enter fullscreen mode Exit fullscreen mode

The handler’s signature reflects its intent: it responds to a semantic category, not to a specific data shape. This decoupling allows handlers to evolve independently of the matching structure and makes it clear which part of the system is responsible for which decision.


Recursive structure without indirection

Recursive traversal emerges naturally from this formulation. Arrays and objects simply invoke the same dispatcher on their elements, without requiring auxiliary state or visitor hierarchies.

inline auto handle_array(int depth) {
  return [=](const json& arr) {
    indent(depth);
    std::cout << "array [" << arr.size() << "]\n";
    for (const auto& v : arr) {
      parse_json(v, depth + 1);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

The recursion is explicit, local, and unsurprising. There is no hidden control flow and no implicit coupling between traversal and dispatch strategy.


Why this structure scales

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, and 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.

The result is code that remains readable as a description of semantics, even as complexity increases.


Toward structural extraction

In this example, handlers inspect JSON values internally after binding. A natural 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.

This would move structural decomposition out of handlers and into the pattern layer itself, further clarifying the intent of each case. The current formulation already makes this extension straightforward, because matching, binding, and handling are explicitly separated.


Closing remarks

This JSON dispatcher is small by design, but it is not a toy example. It demonstrates how pattern matching can serve as a control-flow language embedded in C++, even in the absence of native language support. By treating JSON as a semantic sum type and making dispatch structure explicit, the resulting code becomes easier to extend, audit, and reason about.

The full implementation, including all predicates and handlers, is available at
https://github.com/sentomk/patternia/tree/main/samples/json_dispatch .

Top comments (0)