Your requirements are to create a JsonConverter<double> satisfying the following:
When formatting double values in fixed format, when the value is an integer a .0 fractional portion must be appended.
No change when formatting in exponential format.
No change when formatting non-finite doubles such as double.PositiveInfinity.
No requirement to support JsonNumberHandling options WriteAsString or AllowReadingFromString.
No intermediate parsing to JsonDocument.
In .NET 6 and later you may format your double manually and write it out with Utf8JsonWriter.WriteRawValue(). The following converter functions as required:
public class DoubleConverter : JsonConverter<double>
{
const bool skipInputValidation = true; // Set to true to prevent intermediate parsing. Be careful to ensure your raw JSON is well-formed.
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
{
Span<byte> utf8bytes = stackalloc byte[33]; // JsonConstants.MaximumFormatDecimalLength + 2, https://github.com/dotnet/runtime/blob/v6.0.11/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L85
if (!double.IsFinite(value))
// Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
else if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten))
{
// Check to make sure the value was actually serialized as an integer and not, say, using scientific notation for large values.
if (IsInteger(utf8bytes, bytesWritten))
{
utf8bytes[bytesWritten++] = (byte)'.';
utf8bytes[bytesWritten++] = (byte)'0';
}
writer.WriteRawValue(utf8bytes.Slice(0, bytesWritten), skipInputValidation);
}
else // Buffer was too small?
writer.WriteNumberValue(value);
}
static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
{
if (bytesWritten <= 0)
return false;
var start = utf8bytes[0] == '-' ? 1 : 0;
for (var i = start; i < bytesWritten; i++)
if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
return false;
return start < bytesWritten;
}
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
// TODO: Handle "NaN", "Infinity", "-Infinity"
reader.GetDouble();
}
Notes:
For performance I format the value to a utf8 stackalloc'ed byte array using Utf8Formatter, then add the .0 if required, then finally write using skipInputValidation = true. Doing so should result in best performance, as Utf8JsonWriter is designed to write directly to utf8 buffers or streams rather than to utf16 text writers whose contents are subsequently encoded to utf8.
Utf8Formatter produces locale-invariant output, but if you use a ToString() method such as f.ToString("0.0################"), be sure to do so in the invariant locale like so:
f.ToString("0.0################", NumberFormatInfo.InvariantInfo);
This guarantees that the correct JSON decimal separator . will be used even in locales where a comma is used.
The double.IsFinite(value) check is intended to serialize non-finite values like double.PositiveInfinity correctly. Upon experimentation I have found that Utf8JsonWriter.WriteNumberValue(value) throws unconditionally for these types of value so the serializer must be called to properly handle them when JsonNumberHandling.AllowNamedFloatingPointLiterals is enabled.
Demo fiddle #1 here.
In .NET 5 and earlier Utf8JsonWriter.WriteRawValue() do not exist, so, as suggested by mjwills in comments, you can convert the double to a decimal with the required fractional component, then write that to JSON as follows:
public class DoubleConverter : JsonConverter<double>
{
// 2^49 is the largest power of 2 with fewer than 15 decimal digits.
// From experimentation casting to decimal does not lose precision for these values.
const double MaxPreciselyRepresentedIntValue = (1L<<49);
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
{
bool written = false;
// For performance check to see that the incoming double is an integer
if ((value % 1) == 0)
{
if (value < MaxPreciselyRepresentedIntValue && value > -MaxPreciselyRepresentedIntValue)
{
writer.WriteNumberValue(0.0m + (decimal)value);
written = true;
}
else
{
// Directly casting these larger values from double to decimal seems to result in precision loss, as noted in https://stackoverflow.com/q/7453900/3744182
// And also: https://learn.microsoft.com/en-us/dotnet/api/system.convert.todecimal?redirectedfrom=MSDN&view=net-5.0#System_Convert_ToDecimal_System_Double_
// > The Decimal value returned by Convert.ToDecimal(Double) contains a maximum of 15 significant digits.
// So if we want the full G17 precision we have to format and parse ourselves.
//
// Utf8Formatter and Utf8Parser should give the best performance for this, but, according to MSFT,
// on frameworks earlier than .NET Core 3.0 Utf8Formatter does not produce roundtrippable strings. For details see
// https://github.com/dotnet/runtime/blob/eb03e0f7bc396736c7ac59cf8f135d7c632860dd/src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.WriteValues.Double.cs#L103
// You may want format to string and parse in earlier frameworks -- or just use JsonDocument on these earlier versions.
Span<byte> utf8bytes = stackalloc byte[32];
if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten)
&& IsInteger(utf8bytes, bytesWritten))
{
utf8bytes[bytesWritten++] = (byte)'.';
utf8bytes[bytesWritten++] = (byte)'0';
if (Utf8Parser.TryParse(utf8bytes.Slice(0, bytesWritten), out decimal d, out var _))
{
writer.WriteNumberValue(d);
written = true;
}
}
}
}
if (!written)
{
if (double.IsFinite(value))
writer.WriteNumberValue(value);
else
// Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
}
}
static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
{
if (bytesWritten <= 0)
return false;
var start = utf8bytes[0] == '-' ? 1 : 0;
for (var i = start; i < bytesWritten; i++)
if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
return false;
return start < bytesWritten;
}
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
// TODO: Handle "NaN", "Infinity", "-Infinity"
reader.GetDouble();
}
Notes:
This works because decimal (unlike double) retains trailing zeros, as mentioned in the documentation remarks.
Unconditionally casting a double to a decimal can lose precision for large values, so simply doing
writer.WriteNumberValue(0.0m + (decimal)value);
to force a minimal number of digits is not recommended. (E.g. serializing 9999999999999992 would result in 9999999999999990.0 rather than 9999999999999992.0.)
However, according to the Wikipedia page Double-precision floating-point format: Precision limitations on integer values, integers from −2^53 to 2^53 can exactly represented as a double, so casting to decimal and forcing a minimal number of digits can be used for values in that range.
Other than that, there is no way to directly set the number of digits of a .Net decimal in runtime beyond parsing it from some textual representation. For performance I use Utf8Formatter and Utf8Parser, however in frameworks earlier than .NET Core 3.0 this might lose precision, and regular string formatting and parsing should be used instead. For details see the code comments for Utf8JsonWriter.WriteValues.Double.cs.
You asked, is there a way to access the private API directly?
You can always use reflection to call a private method as shown in How do I use reflection to invoke a private method?, however this is not recommended as internal methods can be changed at any time, thereby breaking your implementation. Beyond that there is no public API to write "raw" JSON directly, other than parsing it to a JsonDocument then writing that. I had to use the same trick in my answer to Serialising BigInteger using System.Text.Json.
You asked, can I instantiate a JsonElement directly without the whole parsing rigmarole?
This is not possible as of .NET 5. As shown in its source code, the JsonElement struct simply contains a reference to its parent JsonDocument _parent along with a location index indicating where the element is located within the document.
In fact in .NET 5 when you deserialize to a JsonElement using JsonSerializer.Deserialize<JsonElement>(string), internally JsonElementConverter reads the incoming JSON into a temporary JsonDocument, clones its RootElement, then disposes of the document and returns the clone.
The special case for value < MaxPreciselyRepresentedIntValue is intended to maximize performance by avoiding any round-tripping to a textual representation when possible.
I haven't actually profiled to confirm that this is faster than doing a textual round-trip however.
Demo fiddle #2 here which includes some unit tests asserting that the converter generates the same output as Json.NET for a wide range of integer double values, as Json.NET always appends a .0 when serializing these.