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