JSON serialization
Bodu.Numerics.Serialization.Json is the companion package that round-trips the Bodu.Numerics value types through System.Text.Json. It covers Fraction<T>, Interval<T>, DiscreteInterval<T>, IntervalSet<T>, BigDecimal, and Complex<T>.
The core Bodu.Numerics library is deliberately serialization-agnostic - the value types carry no [JsonConverter] attribute and take no dependency on System.Text.Json. JSON support is opt-in through this package, so a consumer of just Fraction<T> pays nothing for the serializer. Install it alongside the core package:
dotnet add package Bodu.Numerics.Serialization.Json
Registration
Register the converters once per JsonSerializerOptions with AddNumericsJsonConverters, then serialize as normal:
using System.Text.Json;
using Bodu.Numerics;
using Bodu.Numerics.Serialization.Json;
var options = new JsonSerializerOptions().AddNumericsJsonConverters();
string json = JsonSerializer.Serialize(new Fraction<int>(3, 4), options);
// → {"numerator":3,"denominator":4}
Fraction<int> value = JsonSerializer.Deserialize<Fraction<int>>(json, options);
// → 3/4
AddNumericsJsonConverters registers a coherent converter set for every numeric type from one policy value. The IntervalPair<T> and DiscreteIntervalPair<T> result types are transient and are not serializable; call ToIntervalSet() and serialize the resulting IntervalSet<T> instead.
Choosing a policy
Pass a NumericsJsonPolicy to select the wire shape:
var compact = new JsonSerializerOptions()
.AddNumericsJsonConverters(NumericsJsonPolicy.Compact);
| Policy | Fraction<T> shape |
Interval<T> shape |
Use for |
|---|---|---|---|
Strict (default) |
object { "numerator": 3, "denominator": 4 } |
object { "lower", "upper", "lowerInclusive", "upperInclusive" }, { "empty": true }, or the lowerUnbounded / upperUnbounded markers for infinite sides |
Canonical persistence and interchange. |
Lenient |
as Strict, plus a top-level string is accepted on read |
as Strict, plus "min"/"max" aliases and defaulted inclusivity |
Spreadsheet / external-feed ingest. |
Compact |
string "3/4" |
ISO 31-11 bracket string "[1, 5)", or "∅" for empty |
Compact payloads where size matters. |
DiscreteInterval<T> serializes through the Interval<T> shape over its canonical closed integer bounds (compact form "[1, 5]"), and IntervalSet<T> serializes as a JSON array of its Interval<T> pieces (empty set → []). Both honour the selected policy.
BigDecimal serializes as the canonical object { "unscaledValue": 12340, "scale": 3 } under Strict (the unscaled value is a raw JSON number, so an arbitrary-magnitude mantissa round-trips exactly) and as the plain decimal string "12.340" under Compact. Lenient reads either shape. The string form is used for Compact - rather than a bare JSON number - because many consumers narrow long numbers to IEEE-754 double.
Complex<T> serializes as the canonical object { "real": 3, "imaginary": 4 } under Strict and Lenient - a finite component is a JSON number, while a non-finite one is written as the string "NaN", "Infinity", or "-Infinity" (either form is accepted on read) - and as the "<3; 4>" string under Compact, delegating to Complex<T>.ToString / TryParse under the invariant culture.
Under Strict and Lenient, property names compare case-insensitively, duplicate properties are rejected, and unknown properties are ignored. Lenient writes the same shape as Strict - it only differs on read, where it is an import convenience; persist with Strict or Compact. Compact reads delegate to each type's TryParse path under the invariant culture, so payloads are stable regardless of the ambient culture.
Worked example - each policy
var options = new JsonSerializerOptions().AddNumericsJsonConverters(); // Strict
var compact = new JsonSerializerOptions().AddNumericsJsonConverters(NumericsJsonPolicy.Compact);
var frac = new Fraction<int>(8, 15);
JsonSerializer.Serialize(frac, options); // → {"numerator":8,"denominator":15}
JsonSerializer.Serialize(frac, compact); // → "8/15"
var window = Interval<int>.ClosedOpen(1, 5);
JsonSerializer.Serialize(window, options);
// → {"lower":1,"upper":5,"lowerInclusive":true,"upperInclusive":false}
JsonSerializer.Serialize(window, compact); // → "[1, 5)"
JsonSerializer.Serialize(Interval<int>.AtLeast(1), options);
// → {"lower":1,"upperUnbounded":true,"lowerInclusive":true}
JsonSerializer.Serialize(Interval<int>.Empty, options); // → {"empty":true}
JsonSerializer.Serialize(Interval<int>.Empty, compact);
// → the empty-set glyph "∅" (non-ASCII characters are escaped per the options' encoder)
var set = IntervalSet<int>.Of(Interval<int>.Closed(1, 3), Interval<int>.Closed(8, 9));
JsonSerializer.Serialize(set, compact); // → ["[1, 3]","[8, 9]"]
Registration surfaces
Two surfaces exist; pick the narrowest one that covers your need:
| Surface | Scope | Policy selection |
|---|---|---|
options.AddNumericsJsonConverters(policy) |
Everything serialized with that JsonSerializerOptions |
Any policy; registers a coherent factory set for every numeric type. |
Manual options.Converters.Add(...) of a factory or closed converter |
Whatever you add - e.g. only fractions, or only one backing type | Any policy, per instance: new FractionJsonConverterFactory(NumericsJsonPolicy.Compact) covers every Fraction<T>; new FractionJsonConverter<int>(NumericsJsonPolicy.Compact) covers Fraction<int> only. |
AddNumericsJsonConverters returns the same JsonSerializerOptions instance for inline chaining. It throws ArgumentNullException for a null options instance, ArgumentOutOfRangeException for an undefined policy value, and InvalidOperationException when the options instance has already been used for (de)serialization and its Converters collection has become read-only - configure options before first use.
Convenience helpers
FractionJsonExtensions offers ToJson() / FromJson<T>(string) wrappers that configure a fresh options instance per call:
using Bodu.Numerics.Serialization.Json;
string text = new Fraction<int>(-7, 8).ToJson(); // "{"numerator":-7,"denominator":8}"
Fraction<int> back = FractionJsonExtensions.FromJson<int>(text);
These helpers call the reflection-based JsonSerializer and are annotated [RequiresUnreferencedCode] / [RequiresDynamicCode] - using them in a trimmed or native-AOT app produces the standard analyzer warnings. For repeated serialization, build one JsonSerializerOptions with AddNumericsJsonConverters and reuse it.
Trimming and AOT
The converters are reflection-free at the value level: AddNumericsJsonConverters registers the factory set, and you point a source-generated JsonSerializerContext at your DTO so the trimmer can see the closed types. Prefer that path over the ToJson() / FromJson() helpers whenever trimming or AOT is in play.
How the converters resolve the generic parameter
Fraction<T>, Interval<T>, DiscreteInterval<T>, IntervalSet<T>, and Complex<T> are open generics, so the registered entries are factories that bind the concrete T per request and produce the matching closed converter. You never instantiate the closed converters directly - register the factory (via AddNumericsJsonConverters or by adding it to Converters) and serialize as normal. BigDecimal is non-generic, so it registers as a single BigDecimalJsonConverter rather than a factory.
Custom backing types - Fraction<BigInteger> without precision loss
The object-form converter writes each component as a raw JSON number - not through the writer's Int64 / decimal primitives - so a BigInteger-backed fraction round-trips at any magnitude:
using System.Numerics;
var options = new JsonSerializerOptions().AddNumericsJsonConverters();
var precise = new Fraction<BigInteger>(
BigInteger.Parse("123456789012345678901234567890"), 7);
string json = JsonSerializer.Serialize(precise, options);
// → {"numerator":123456789012345678901234567890,"denominator":7}
Fraction<BigInteger> back = JsonSerializer.Deserialize<Fraction<BigInteger>>(json, options);
// → exact round-trip; nothing was truncated through long or decimal
On read, each component accepts either a JSON number or a numeric string token, parsed as T under the invariant culture - so systems that cannot carry arbitrary-precision JSON numbers can quote them instead:
JsonSerializer.Deserialize<Fraction<BigInteger>>(
"""{ "numerator": "123456789012345678901234567890", "denominator": "7" }""", options);
// → same value as above
The same number-or-string tolerance applies to Interval<T> endpoint values under Strict and Lenient.
Failure modes
Malformed payloads surface as JsonException on read; the converters never silently coerce:
| Input | Policy | Result |
|---|---|---|
Token is not an object (e.g. a bare string under Strict) |
Strict |
JsonException - object form expected. (Lenient routes a top-level string through the compact parser instead.) |
Missing "numerator" / "denominator" (fraction) or a bounded side's "lower" / "upper" (interval) |
Strict, Lenient |
JsonException naming the missing property. |
Duplicate property (e.g. "numerator" twice) |
Strict, Lenient |
JsonException - duplicates are rejected, never last-wins. |
"denominator": 0 |
Strict, Lenient |
JsonException - a zero denominator is invalid on the wire. |
Missing "lowerInclusive" / "upperInclusive" on a bounded side |
Strict |
JsonException; Lenient defaults the missing flags to closed. |
{ "empty": true } carrying extra endpoint or unbounded properties |
Strict, Lenient |
JsonException - the empty form must stand alone. |
| Component value that is neither a number nor a parseable numeric string | all | JsonException reporting the type mismatch. |
Compact token that is not a string, or a string TryParse rejects (e.g. "3/", "[1, )") |
Compact |
JsonException carrying the offending text. |
Non-array token for an IntervalSet<T> |
all | JsonException - an interval set is a JSON array of pieces. |
Unknown properties are ignored (skipped), matching the BCL convention for forward compatibility.
See also
- Working with
Fraction<T>- the rational type being serialized. - Working with
Interval<T>- the interval type and its bracket notation. - Formatting and parsing
Fraction<T>- the text forms theCompactpolicy delegates to. - Bodu.Numerics guides - the member overview for this package.
- Numerics & Financial topic guides - every guide in the topic.
- Numerics & Financial topic overview - package boundaries and the decision table.
NumericsJsonPolicy·FractionJsonConverterFactory·IntervalJsonConverterFactory- Bodu.Numerics.Serialization.Json API reference - full namespace overview.