dotnet / dotnet/runtime

[API Proposal]: Add JsonNumber arbitrary-precision number type to System.Text.Json

Open
#125,611 4 comments 4 reactions 0 assignees View on GitHub
api-suggestion area-System.Text.Json
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

> [!NOTE]
> This proposal was drafted with the help of an AI agent. Please review for accuracy and remove this notice once you're satisfied with the content.

## Background and motivation

.NET has no single type that can faithfully represent an arbitrary JSON number. `decimal` loses values outside its 96-bit mantissa or 10^28 scale. `double` loses precision for integers beyond 2^53. `BigInteger` has no fractional component. This forces users of `System.Text.Json` into awkward workarounds:

- **Exchange type gap**: Libraries exchanging JSON numbers between different numeric representations (e.g., financial APIs, scientific data, blockchain) must resort to raw strings, losing type safety and validation.
- **JsonNode usability issues**: `JsonNode.Parse("42")` produces a `JsonValueOfElement` that stores raw `JsonElement` bytes, making `GetValue()` work but `GetValue()` fail depending on internal representation. Cross-type comparisons fail unexpectedly — `JsonNode.DeepEquals(JsonValue.Create(4), JsonValue.Create(4.0m))` returns `false` ([#97490](https://github.com/dotnet/runtime/issues/97490)), and `JsonValue` is "almost unusable" for common operations ([#64472](https://github.com/dotnet/runtime/issues/64472)).
- **Reader/Writer gap**: `Utf8JsonReader` can read numbers as `int`, `long`, `decimal`, `double` — but there is no "give me this number without losing anything" API. Users who need to round-trip arbitrary JSON numbers must copy raw bytes manually.

**Prior art:** Java's `java.math.BigDecimal`, Python's `decimal.Decimal`, and Rust's `serde_json::Number` all provide arbitrary-precision decimal types used as JSON interchange types. Newtonsoft.Json internally uses `BigInteger` for large numbers but does not expose a public arbitrary-precision number type.

## API Proposal

### New type: `JsonNumber`

```csharp
namespace System.Text.Json;

public readonly partial struct JsonNumber : IEquatable, IComparable,
#if NET
ISpanFormattable, ISpanParsable, IUtf8SpanFormattable, IUtf8SpanParsable
#else
IFormattable
#endif
{
// Properties
public static JsonNumber Zero { get; }
public bool IsZero { get; }
public bool IsNegative { get; }
public bool IsInteger { get; }

// Parsing (string, ROS, ROS)
public static JsonNumber Parse(ReadOnlySpan utf8Text);
public static JsonNumber Parse(string text);
public static JsonNumber Parse(ReadOnlySpan text);
public static bool TryParse(ReadOnlySpan utf8Text, out JsonNumber result);
public static bool TryParse([NotNullWhen(true)] string? text, out JsonNumber result);
public static bool TryParse(ReadOnlySpan text, out JsonNumber result);

// Formatting
public override string ToString();
public string ToString(string? format, IFormatProvider? formatProvider);
#if NET
public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null);
public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format = default, IFormatProvider? provider = null);
#endif

// Equality and comparison (semantic: 1 == 1.0 == 10e-1)
public bool Equals(JsonNumber other);
public override bool Equals(object? obj);
public override int GetHashCode();
public int CompareTo(JsonNumber other);
public static bool operator ==(JsonNumber left, JsonNumber right);
public static bool operator !=(JsonNumber left, JsonNumber right);
public static bool operator <(JsonNumber left, JsonNumber right);
public static bool operator <=(JsonNumber left, JsonNumber right);
public static bool operator >(JsonNumber left, JsonNumber right);
public static bool operator >=(JsonNumber left, JsonNumber right);

// Conversions IN (implicit for lossless, explicit for potentially lossy)
public static implicit operator JsonNumber(byte value);
public static implicit operator JsonNumber(sbyte value);
public static implicit operator JsonNumber(short value);
public static implicit operator JsonNumber(ushort value);
public static implicit operator JsonNumber(int value);
public static implicit operator JsonNumber(uint value);
public static implicit operator JsonNumber(long value);
public static implicit operator JsonNumber(ulong value);
public static implicit operator JsonNumber(decimal value);
public static explicit operator JsonNumber(float value); // explicit: NaN/Infinity rejected
public static explicit operator JsonNumber(double value); // explicit: NaN/Infinity rejected
#if NET
public static explicit operator JsonNumber(Half value); // explicit: NaN/Infinity rejected
public static implicit operator JsonNumber(Int128 value);
public static implicit operator JsonNumber(UInt128 value);
#endif

// Conversions OUT (all explicit: potentially lossy)
public static explicit operator byte(JsonNumber value);
public static explicit operator sbyte(JsonNumber value);
public static explicit operator short(JsonNumber value);
public static explicit operator ushort(JsonNumber value);
public static explicit operator int(JsonNumber value);
public static explicit operator uint(JsonNumber value);
public static explicit operator long(JsonNumber value);
public static explicit operator ulong(JsonNumber value);
public static explicit operator float(JsonNumber value);
public static explicit operator double(JsonNumber value);
public static explicit operator decimal(JsonNumber value);
#if NET
public static explicit operator Half(JsonNumber value);
public static explicit operator Int128(JsonNumber value);
public static explicit operator UInt128(JsonNumber value);
#endif

// TryGet methods (non-throwing alternatives)
public bool TryGetByte(out byte value);
public bool TryGetSByte(out sbyte value);
public bool TryGetInt16(out short value);
public bool TryGetUInt16(out ushort value);
public bool TryGetInt32(out int value);
public bool TryGetUInt32(out uint value);
public bool TryGetInt64(out long value);
public bool TryGetUInt64(out ulong value);
public bool TryGetSingle(out float value);
public bool TryGetDouble(out double value);
public bool TryGetDecimal(out decimal value);
#if NET
public bool TryGetHalf(out Half value);
public bool TryGetInt128(out Int128 value);
public bool TryGetUInt128(out UInt128 value);
#endif
}
```

### Additions to existing types

```csharp
namespace System.Text.Json;

public ref partial struct Utf8JsonReader
{
public JsonNumber GetJsonNumber();
public bool TryGetJsonNumber(out JsonNumber value);
}

public sealed partial class Utf8JsonWriter
{
public void WriteNumberValue(JsonNumber value);
public void WriteNumber(ReadOnlySpan utf8PropertyName, JsonNumber value);
public void WriteNumber(ReadOnlySpan propertyName, JsonNumber value);
public void WriteNumber(string propertyName, JsonNumber value);
public void WriteNumber(JsonEncodedText propertyName, JsonNumber value);
}

namespace System.Text.Json.Nodes;

public partial class JsonNode
{
public static explicit operator JsonNumber(JsonNode value);
public static explicit operator JsonNumber?(JsonNode? value);
public static implicit operator JsonNode(JsonNumber value);
public static implicit operator JsonNode?(JsonNumber? value);
}

public abstract partial class JsonValue
{
public static JsonValue Create(JsonNumber value, JsonNodeOptions? options = default);
public static JsonValue? Create(JsonNumber? value, JsonNodeOptions? options = default);
}

namespace System.Text.Json.Serialization.Metadata;

public static partial class JsonMetadataServices
{
public static JsonConverter JsonNumberConverter { get; }
}
```

### Behavioral change: JsonNode number normalization

When `JsonNode` trees are created via `JsonNode.Parse()` or `JsonSerializer.Deserialize()`, number values are now internally normalized to `JsonValuePrimitive` instead of storing raw element bytes. This means:

- `node.GetValue()`, `node.GetValue()`, `node.GetValue()` all work on any parsed number node.
- `JsonNode.DeepEquals` compares numbers semantically: `DeepEquals(Parse("1"), Parse("1.0"))` returns `true`.
- `node.GetValue()` returns the narrowest CLR type (`int` → `long` → `ulong` → `decimal` → `double` → `JsonNumber`) for backward compatibility with libraries like json-everything that inspect runtime types.
- Numbers with exponents exceeding `int` range (e.g., `1e2147483648`) fall back to the existing `JsonValueOfElement` representation.

## API Usage

### 1. Lossless number round-trip through JSON

```csharp
// Read a number that exceeds any CLR numeric type
string json = """{"balance": 99999999999999999999999999999.99}""";
JsonNode doc = JsonNode.Parse(json);
JsonNumber balance = doc["balance"].GetValue();

// Inspect and convert
Console.WriteLine(balance.IsInteger); // false
Console.WriteLine(balance); // 99999999999999999999999999999.99

// Write it back without any precision loss
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
writer.WriteNumberValue(balance);
```

### 2. Unified number exchange type

```csharp
// JsonNumber works as a common currency between different numeric representations
JsonNumber fromInt = 42; // implicit
JsonNumber fromDecimal = 3.14m; // implicit
JsonNumber fromDouble = (JsonNumber)1e100; // explicit (NaN/Infinity rejected)
JsonNumber parsed = JsonNumber.Parse("1e999"); // arbitrary precision

// Extract to the type you need
if (parsed.TryGetInt64(out long l))
Console.WriteLine($"Fits in long: {l}");
else if (parsed.TryGetDecimal(out decimal d))
Console.WriteLine($"Fits in decimal: {d}");
else
Console.WriteLine($"Big number: {parsed}");
```

### 3. Serialization/deserialization

```csharp
public record Transaction(string Id, JsonNumber Amount);

string json = """{"Id": "tx-1", "Amount": 123456789.123456789012345678901234}""";
var tx = JsonSerializer.Deserialize(json);
Console.WriteLine(tx.Amount); // 123456789.123456789012345678901234 — no precision loss

string roundTripped = JsonSerializer.Serialize(tx);
// Identical to input
```

### 4. JsonNode cross-type interoperability (behavioral improvement)

```csharp
// Before: these would fail or return inconsistent results
JsonNode node = JsonNode.Parse("42");
int i = node.GetValue(); // ✅ works
long l = node.GetValue(); // ✅ now works (was broken with JsonValueOfElement)
JsonNumber n = node.GetValue(); // ✅ new capability

// Before: DeepEquals was inconsistent across representations
JsonNode a = JsonValue.Create(4);
JsonNode b = JsonValue.Create(4.0m);
JsonNode.DeepEquals(a, b); // ✅ now true (was false per #97490)
```

## Design Decisions

- **`decimal` + `BigDecimalData` union (24 bytes)**: The overwhelming majority of real-world JSON numbers fit in `decimal`. The `_bigData` field is null for those, avoiding heap allocation. Only numbers exceeding decimal range allocate.
- **Semantic equality**: `1`, `1.0`, and `10e-1` are equal. This matches JSON semantics and fixes `DeepEquals` inconsistencies. The implementation normalizes by stripping trailing significand zeros during parsing.
- **`float`/`double`/`Half` → `JsonNumber` is explicit**: These types can hold `NaN` and `Infinity` which are not valid JSON numbers. The explicit operator makes the rejection visible at the call site.
- **All `JsonNumber` → CLR conversions are explicit**: Every outbound conversion is potentially lossy (narrowing or truncation), so all are explicit with throwing operators and `TryGet*` non-throwing alternatives.
- **Exponent capped to `int` range**: Supports numbers like `1e2147483647` but not `1e9999999999999`. This covers all practical use cases while keeping the representation compact. Numbers outside this range fall back gracefully to `JsonValueOfElement` in JsonNode trees.
- **No `BigInteger` dependency**: The significand uses a compact `uint[]` array with custom arithmetic, avoiding a dependency on `System.Numerics`.
- **`GetValue()` returns narrowest CLR type**: For backward compatibility with libraries that inspect the runtime type of `GetValue()`, the implementation returns `int`, `long`, `ulong`, `decimal`, or `double` before falling back to boxed `JsonNumber`.

## Alternative Designs

- **Use `BigInteger` + scale**: Would add a dependency on `System.Numerics` and still requires a custom wrapper for the exponent. The internal `uint[]` + exponent representation is simpler and self-contained.
- **Store raw string bytes**: Would avoid all parsing overhead but makes equality, comparison, and numeric conversion expensive. Also increases memory usage for the common case (small numbers).
- **Normalize programmatic `JsonValue.Create(42)` to `JsonNumber`**: Considered and rejected — it would change the stored type for programmatically-created nodes, potentially breaking code that does `GetValue()` on a node it just created with an `int`.

## Risks

- **Source compatibility**: `GetValue()` on parsed number nodes now throws `InvalidOperationException` (previously succeeded on `JsonValueOfElement` by returning raw text). This is technically a behavioral change but aligns with the principle that numbers are not strings.
- **`GetValue()` type changes**: Previously returned `JsonElement` for parsed number nodes; now returns `int`/`long`/`decimal`/etc. Code inspecting the runtime type may see different types. The narrowest-type strategy was chosen to match what `json-everything` and similar libraries expect.
- **No binary breaking changes**: All additions are new types and new members on existing types.
- **No source breaking changes from overload resolution**: `JsonNumber` operators don't conflict with existing `JsonNode` operators; the `JsonNumber` conversion operators use distinct signatures.

## Open Questions

- Should `JsonNumber` implement arithmetic operators (`+`, `-`, `*`, `/`)? The current design is focused on representation and interchange, not computation. Arithmetic could be added later without breaking changes.
- Should `GetValue()` succeed on `JsonNumber`-backed nodes (returning `ToString()`)? Currently rejected because existing code uses `TryGetValue()` as a type check (notably the reference handler's `$id`/`$ref` validation). This could be revisited if a separate "is this a string value?" API is added.

## Related Issues

- [#64472](https://github.com/dotnet/runtime/issues/64472) — "System.Text.Json.Nodes.JsonValue is almost unusable"
- [#97490](https://github.com/dotnet/runtime/issues/97490) — "`JsonNode.DeepEquals()` erroneously considers number representation"
- [#82774](https://github.com/dotnet/runtime/issues/82774) — Number precision control in serialization

## Prototype

https://github.com/eiriktsarpalis/runtime/commit/294317d51297134acaa85d22f6cb4e08eebb0a13

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.