Hi,

Some feedback on JEP 540 (Simple JSON API), currently proposed to
target JDK 28 as an incubator API.

The API already follows a consistent try* pattern for two of the ways
reading JSON can fail:

  - missing member:  JsonValue.tryGet(name)  -> Optional<JsonValue>
  - JSON null:       JsonValue.tryValue()    -> Optional<JsonValue>

I would like to propose extending the same idiom to the two remaining
failure modes, so that fully defensive code never needs try/catch:

1. Malformed input:

       Json.tryParse(String)  -> Optional<JsonValue>
       Json.tryParse(char[])  -> Optional<JsonValue>

   returning an empty Optional where parse would throw
   JsonParseException. This is for callers that do not care why
   parsing failed, only whether it did: content sniffing, validating
   user input, scanning mixed log lines, accepting "JSON or plain
   string" values. In such code the input is expected to sometimes
   not be JSON, the error detail goes unused, and the exception is
   pure control flow:

       Optional<JsonValue> v = Json.tryParse(line);

   versus today:

       JsonValue v = null;
       try {
           v = Json.parse(line);
       } catch (JsonParseException _) { /* ignore */ }

2. Wrong type / unrepresentable number:

       Optional<String>                 tryAsString()
       Optional<Integer>                tryAsInt()
       Optional<Long>                   tryAsLong()
       Optional<Double>                 tryAsDouble()
       Optional<Boolean>                tryAsBoolean()
       Optional<List<JsonValue>>        tryAsList()
       Optional<Map<String, JsonValue>> tryAsMap()

   on JsonValue, returning an empty Optional exactly where the
   corresponding as* method would throw JsonValueException (wrong
   subtype, or numeric value not representable in the target type).

   The numeric variants deliberately return boxed Optional<Integer>
   etc. rather than OptionalInt/OptionalLong/OptionalDouble: the
   primitive optionals have no map/flatMap/filter and cannot appear
   in an Optional chain, which would defeat the composability
   motivation below, while the boxed forms keep the whole try*
   family on a single Optional shape. The throwing as* methods keep
   their primitive returns, so the fail-fast path stays box-free.

The motivation for (2) is composability. The API encourages an
Optional-based style for resilient code, but a chain like

    v.tryGet("count").map(JsonValue::asInt).orElse(0)

reads as resilient while still throwing inside map() if "count" is
present with a non-numeric value. Handling document evolution
defensively today means try/catch around an unchecked exception, or a
switch with a default arm per field -- verbose for the common case
"use the value if present and of the expected type, else a default".

With tryAs*:

    int count = v.tryGet("count")
                 .flatMap(JsonValue::tryAsInt)
                 .orElse(0);

The tryAs* methods cover JSON null uniformly as well: a JsonNull is
simply not a JsonNumber, so tryAsInt() on it is empty, and the
chain above treats "count": null the same as a missing "count".

I realize fail-fast exceptions with path/location detail are a
deliberate goal of the JEP, and that pattern matching over
JsonNumber/JsonString is the recommended tool for variant structure.
Both remain the right default, and none of the proposed methods would
replace the throwing ones. But tryGet and tryValue already establish
that resilient code deserves first-class non-throwing accessors;
tryParse and tryAs* would complete that idiom rather than change the
design philosophy.

Note also that tryAs* has no clean substitute today. A bare
instanceof narrows the type but does not make the conversion safe:
asInt() can throw on a genuine JsonNumber (overflow, non-integral
value). Primitive type patterns (JEP 532, still in preview) will
close such gaps in general, but they need a primitive expression to
match on, and JsonNumber's only numeric accessors are the throwing
conversions themselves: n.asDouble() instanceof int i can still
throw (overflow to infinity) and cannot reproduce asLong()'s exact
semantics above 2^53. Neither instanceof today nor primitive
patterns tomorrow yield a non-throwing conversion.

I understand this would not change the JEP itself; I am proposing it
as API surface to consider either before the initial integration or
as an evolution during incubation in a later release.

Regards,
Ivan Sulimov

Reply via email to