Hi core-libs-dev team

I’ve been experimenting with the early design proposal for JEP 540 (Simple JSON
API) and wanted to share some feedback regarding document traversal ergonomics,
particularly around missing keys, dynamic paths, and type coercion.


Motivation:

In dynamic or deeply nested JSON documents, requiring tryGet() and explicit
bounds checks at every node introduces significant boilerplate when traversing
optional or missing fields and array indices.

Instead of throwing exceptions on unpopulated paths, I would like to suggest
evaluating a Null-Object representation for missing elements. Here I introduce
a dedicated `JsonUndefined` type.


Key Concepts:

1. Safe chaining (null object pattern):
   Calling get(String) or get(int) on missing nodes return JsonUndefined
   rather than throwing. Subsequent traversal on JsonUndefined safely returns
   JsonUndefined again.

2. Explicit monadic escape:
   Providing a .toOptional() method on the base interface where standard nodes
   return Optional.of(this), while JsonNull and JsonUndefined return
   Optional.empty().

3. Permissive coercion for document evolution:
   JEP 540 notes that documents evolve over time. Where strings contain valid
   numeric representations like `"tid": "11"`, providing implicit coercion on
   conversion methods avoids brittle type mismatches without cluttering user
   code.

4. Pattern matching integration:
   JsonUndefined integrates seamlessly into pattern matching, making absent
   values explicit:

       root.get("threadDump")
           .get("threadContainers")
           .asList()
           .forEach(container -> {
               // Safe string conversion regardless of numeric/string type
               IO.println("tid: " + container.get("tid").asString());

               var thread = switch (container.get("waitingOn")) {
                   case JsonString s    -> s.asString();
                   case JsonUndefined _ -> "- not present -";
                   case JsonNull _      -> "- explicit null -";
                   default              -> "- other -";
               };
               System.out.println("waitingOn: " + thread);
           });



Implementation Detail:

JsonUndefined extends a common empty/null interface, distinguishing missing
members from explicit JSON null literals (which are preserved during
serialisation):

    sealed interface JsonEmpty extends JsonValue {
        @Override
        default Optional<Json> toOptional() { return Optional.empty(); }
        @Override
        default String asString() { return null; }
        @Override
        default Json get(String key) { return JsonUndefined.INSTANCE; }
        @Override
        default Json get(int idx) { return JsonUndefined.INSTANCE; }
        /* ...remaining as* methods return null... */
    }
    record JsonUndefined() implements JsonEmpty { /* ... */ }
    record JsonNull() implements JsonEmpty { /* ... */ }

    record JsonObject(Map<String, Json> members) implements Json {
        @Override
        public Json get(String key) {
            return members.getOrDefault(key, JsonUndefined.INSTANCE);
        }
        //...
    }


Reference Implementation:

I have put together a standalone, zero-dependency reference implementation
demonstrating this traversal style along with a state-machine parser and
numeric precision model.

Gist: https://gist.github.com/brettryan/96c71b7699b0a798912758dffaf3d298


Further Concepts:

In the spirit of easing navigation, we could provide toOptionalValue() which
would return the value to the consumer without the need to unbox.

    sealed interface JsonValue {
        default Optional<JsonValue> toOptional() { return Optional.of(this); }
        Optional<Object> toOptionalValue();
    }

    sealed interface JsonEmpty extends JsonValue {
        @Override
        default Optional<JsonValue> toOptional() {
            return Optional.empty();
        }
        @Override
        default Optional<Object> toOptionalValue() {
            return Optional.empty();
        }
        /* ... */
    }

    record JsonArray(List<JsonValue> values) implements JsonValue {
        @Override
        public Optional<List<Object>> toOptionalValue() {
            var vals = new ArrayList<Object>(values.size());
            for (var val : values) {
                switch (val) {
                    case null -> {}
                    case JsonEmpty _ -> {}
                    case JsonValue j -> {
                        // we guarantee toOptionalValue here will be present.
                        vals.add(j.toOptionalValue().get());
                    }
                }
            }
            return Optional.of(Collections.unmodifiableList(vals));
        }
        /* ... */
    }

    record JsonString(String value) implements JsonValue {
        @Override
        public Optional<Object> toOptionalValue() {
            return Optional.of(value);
        }
        /* ... */
    }

This would provide a natural fallback behaviour:

    IO.println("waitingOn: ",
               container.get("waitingOn")
                        .toOptionalValue()
                        .orElse("- nothing -"));

Alternatively, JsonValue could be monadic which would be more natural, but
bleeds Optional like implementation into JsonValue.


-
Regards
Brett Ryan

Reply via email to