Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions DESIGN_CHOICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Design choices: numeric handling (`JsonNumber`, BigDecimal/BigInteger)

This repository is a **backport** of the upstream OpenJDK sandbox `java.util.json` work (mirrored here as `jdk.sandbox.java.util.json`). That matters for “why did X disappear?” questions:

- This repo intentionally avoids *inventing* new public API shapes that diverge from upstream, because doing so makes syncing harder and breaks the point of the backport.
- When upstream removes or reshapes API, this repo follows.

## What changed (the “story”)

Older revisions of this backport carried some convenience entry points that accepted `java.math.BigDecimal` and `java.math.BigInteger` when building JSON numbers.

During the last upstream sync, those entry points were removed. **That is consistent with the upstream design direction**: keep `JsonNumber`’s public surface area small and make **lossless numeric interoperability** flow through the **lexical JSON number text** (`JsonNumber.toString()`), not through a growing matrix of overloads.

Put differently: the design is “JSON numbers are text first”, not “JSON numbers are a Java numeric tower”.

## `JsonNumber` is not a primitive (and that’s the point)

The core abstraction here is `JsonValue`, a **sealed interface** with one subtype per JSON kind:

- `JsonString`
- `JsonNumber`
- `JsonObject`
- `JsonArray`
- `JsonBoolean`
- `JsonNull`

So `JsonNumber` is not intended to *replace* Java numeric primitives; it’s the JSON-layer representation of “a number token in a JSON document”.

The deliberate split is:

- **JSON layer**: preserve what was written (especially for numbers), keep round-tripping sane, avoid choosing a single “native numeric type” too early.
- **Application layer**: *you* decide what “native” means (long? double? BigDecimal? BigInteger? domain-specific types?), and you do that conversion explicitly.

## Why upstream prefers `String` (and why BigDecimal constructors are a footgun)

### 1) JSON numbers are arbitrary precision *text*

RFC 8259 defines the *syntax* of a JSON number; it does **not** define a fixed precision or a single canonical format. The API aligns with that by treating the number as a string that:

- can be preserved exactly when parsed from a document
- can be created from a string when you need exact control

### 2) `BigDecimal`/`BigInteger` introduce formatting policy into the API

If `JsonNumber` has `of(BigDecimal)` / `of(BigInteger)`:

- which textual form should be used (`toString()` vs `toPlainString()`)?
- should `-0` be preserved, normalized, or rejected?
- should `1e2` round-trip as `1e2` or normalize to `100`?

Any choice becomes a **semantic commitment**: it changes `toString()`, equality and hash behavior, and round-trip characteristics.

Upstream avoids baking those policy decisions into the core JSON API by:

- providing `JsonNumber.of(String)` as the “I know what text I want” factory
- documenting that you can always interoperate with arbitrary precision Java numerics by converting *from* `toString()`

This intent is explicitly documented in `JsonNumber`’s own `@apiNote`.

### 3) Minimal factories avoid overload explosion

JSON object/array construction in this API already leans toward:

- immutable values
- static factories (`...of(...)`)
- pattern matching / sealed types when consuming values

That style is a natural fit for “a few sharp entry points” rather than the legacy OO pattern of ever-expanding constructor overloads for every “convenient” numeric type.

## Recommended recipes (lossless + explicit)

### Parse → BigDecimal (lossless)

```java
var n = (JsonNumber) Json.parse("3.141592653589793238462643383279");
var bd = new BigDecimal(n.toString()); // exact
```

### Counter-example: converting to `double` can lose information

```java
var n = (JsonNumber) Json.parse("3.141592653589793238462643383279");
double d = n.toDouble(); // finite, but lossy
// BigDecimal.valueOf(d) is NOT equal to the original high-precision value
```

### Counter-example: converting to `long` can throw (even for numbers)

```java
var n = (JsonNumber) Json.parse("5.5");
n.toLong(); // throws JsonAssertionException (not an integral value)
```

### Counter-example: converting to `double` can throw (range overflow)

```java
var n = (JsonNumber) Json.parse("1e309");
n.toDouble(); // throws JsonAssertionException (outside finite double range)
```

### Parse → BigInteger (lossless, when integral)

```java
var n = (JsonNumber) Json.parse("1.23e2");
var bi = new BigDecimal(n.toString()).toBigIntegerExact(); // 123
```

### BigDecimal → JsonNumber (pick your textual policy)

If you want to preserve the *mathematical* value without scientific notation:

```java
var bd = new BigDecimal("1000");
var n = JsonNumber.of(bd.toPlainString()); // "1000"
```

If you’re fine with scientific notation when `BigDecimal` chooses it:

```java
var bd = new BigDecimal("1E+3");
var n = JsonNumber.of(bd.toString()); // "1E+3" (still valid JSON number text)
```

### JSON lexical preservation is not numeric normalization

Two JSON numbers can represent the same numeric value but still be different JSON texts:

```java
var a = (JsonNumber) Json.parse("1e2");
var b = (JsonNumber) Json.parse("100");
assert !a.toString().equals(b.toString()); // lexical difference preserved
```

If your application needs *numeric* equality or canonicalization, perform it explicitly with `BigDecimal` (or your own policy), rather than relying on the JSON value object to do it implicitly.

## Ergonomics: mapping `JsonValue` to native Java types (pattern matching)

If you want the “old style” `Map` / `List` / primitives view, you can build it explicitly using a `switch` over the sealed `JsonValue` hierarchy.

One pragmatic policy for numbers is:

- try `toLong()` first (exact integer in range)
- otherwise fall back to `BigDecimal` from `toString()` (lossless)

```java
static Object toNative(JsonValue v) {
return switch (v) {
case JsonNull ignored -> null;
case JsonBoolean b -> b.bool();
case JsonString s -> s.string();
case JsonNumber n -> {
try {
yield n.toLong();
} catch (JsonAssertionException ignored) {
yield new BigDecimal(n.toString());
}
}
case JsonArray a -> a.elements().stream().map(Design::toNative).toList();
case JsonObject o -> o.members().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> toNative(e.getValue())));
};
}
```

This gives you native ergonomics **without** forcing the core JSON API to guess which numeric type you wanted.

## Runnable examples

This document’s examples are mirrored in code:

- `json-java21/src/test/java/jdk/sandbox/java/util/json/examples/DesignChoicesExamples.java`
- `json-java21/src/test/java/jdk/sandbox/java/util/json/DesignChoicesNumberExamplesTest.java`

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ java -cp ./json-java21/target/java.util.json-*.jar:./json-java21/target/test-cla

*Replace `*` with the actual version number from the JAR filename.*

## Design notes

- **Numeric handling (JsonNumber, BigDecimal/BigInteger)**: see `DESIGN_CHOICES.md`

## API Overview

The API provides immutable JSON value types:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package jdk.sandbox.java.util.json;

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Map;
import java.util.logging.Logger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class DesignChoicesNumberExamplesTest {
private static final Logger LOGGER = Logger.getLogger(DesignChoicesNumberExamplesTest.class.getName());

@Test
void parseHighPrecisionNumberIsLosslessViaToString() {
LOGGER.info("Executing parseHighPrecisionNumberIsLosslessViaToString");

var text = "3.141592653589793238462643383279";
var n = (JsonNumber) Json.parse(text);

assertThat(n.toString()).isEqualTo(text);
assertThat(new BigDecimal(n.toString())).isEqualByComparingTo(new BigDecimal(text));
}

@Test
void convertingToDoubleIsPotentiallyLossy() {
LOGGER.info("Executing convertingToDoubleIsPotentiallyLossy");

var text = "3.141592653589793238462643383279";
var n = (JsonNumber) Json.parse(text);

var lossless = new BigDecimal(n.toString());
var lossy = new BigDecimal(Double.toString(n.toDouble()));

assertThat(lossy).isNotEqualByComparingTo(lossless);
}

@Test
void convertingNonIntegralNumberToLongThrows() {
LOGGER.info("Executing convertingNonIntegralNumberToLongThrows");

var n = (JsonNumber) Json.parse("5.5");
assertThatThrownBy(n::toLong)
.isInstanceOf(JsonAssertionException.class);
}

@Test
void convertingOutOfRangeNumberToDoubleThrows() {
LOGGER.info("Executing convertingOutOfRangeNumberToDoubleThrows");

var n = (JsonNumber) Json.parse("1e309");
assertThatThrownBy(n::toDouble)
.isInstanceOf(JsonAssertionException.class);
}

@Test
void parseExponentFormToBigIntegerExactWorks() {
LOGGER.info("Executing parseExponentFormToBigIntegerExactWorks");

var n = (JsonNumber) Json.parse("1.23e2");
BigInteger bi = new BigDecimal(n.toString()).toBigIntegerExact();

assertThat(bi).isEqualTo(BigInteger.valueOf(123));
}

@Test
void bigDecimalToJsonNumberRequiresChoosingATextPolicy() {
LOGGER.info("Executing bigDecimalToJsonNumberRequiresChoosingATextPolicy");

// Using toPlainString() for a plain number representation
var bdPlain = new BigDecimal("1000");

var plain = JsonNumber.of(bdPlain.toPlainString());
assertThat(plain.toString()).isEqualTo("1000");

// Using toString(), which may produce scientific notation
var bdScientific = new BigDecimal("1E+3");
var scientific = JsonNumber.of(bdScientific.toString());
assertThat(scientific.toString()).isEqualTo("1E+3");
Comment on lines +70 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic in this test can be made clearer and more consistent. The first part of the test defines a BigDecimal variable thousand, but the second part creates a BigDecimal inline. For better readability and to more clearly demonstrate the policies, consider defining separate, descriptively named variables for both cases. This makes the intent of each part of the test more explicit.

        LOGGER.info("Executing bigDecimalToJsonNumberRequiresChoosingATextPolicy");

        // Using toPlainString() for a plain number representation
        var bdPlain = new BigDecimal("1000");
        var plain = JsonNumber.of(bdPlain.toPlainString());
        assertThat(plain.toString()).isEqualTo("1000");

        // Using toString() which may produce scientific notation
        var bdScientific = new BigDecimal("1E+3");
        var scientific = JsonNumber.of(bdScientific.toString());
        assertThat(scientific.toString()).isEqualTo("1E+3");

}

@Test
void lexicalPreservationDiffersFromNumericNormalization() {
LOGGER.info("Executing lexicalPreservationDiffersFromNumericNormalization");

var a = (JsonNumber) Json.parse("1e2");
var b = (JsonNumber) Json.parse("100");

// lexical forms differ
assertThat(a.toString()).isEqualTo("1e2");
assertThat(b.toString()).isEqualTo("100");

// but numeric values compare equal when canonicalized explicitly
assertThat(new BigDecimal(a.toString())).isEqualByComparingTo(new BigDecimal(b.toString()));
}

@Test
void mappingToNativeTypesUsesPatternMatchingAndExplicitNumberPolicy() {
LOGGER.info("Executing mappingToNativeTypesUsesPatternMatchingAndExplicitNumberPolicy");

JsonValue json = Json.parse("""
{
"smallInt": 42,
"decimal": 5.5
}
""");

Object nativeValue = jdk.sandbox.java.util.json.examples.DesignChoicesExamples.toNative(json);
assertThat(nativeValue).isInstanceOf(Map.class);

@SuppressWarnings("unchecked")
var map = (Map<String, Object>) nativeValue;

assertThat(map.get("smallInt")).isEqualTo(42L);
assertThat(map.get("decimal")).isInstanceOf(BigDecimal.class);
assertThat((BigDecimal) map.get("decimal")).isEqualByComparingTo(new BigDecimal("5.5"));
}
}

Loading
Loading