Skip to content

[API Proposal]: Add a JsonUnknownTypeHandling setting for deserializing object to .NET primitive values #98038

Description

@eiriktsarpalis

Background and motivation

Branching off from the conversation in #29960 and #97801 to consider a potential built-in object deserializer that targets .NET primitive values as opposed to targeting the DOM types: JsonNode or JsonElement. The background is enabling users migrating off of Json.NET needing a quick way to support object deserialization, provided that the deserialized object is "simple enough". This approach is known to create problems w.r.t. loss of fidelity when roundtripping, which is why it was explicitly ruled out when STJ was initially being designed. It is still something we might want to consider as an opt-in accelerator for users that do depend on that behaviour.

This proposal would map JSON to .NET types using the following recursive schema:

  • JSON null maps to .NET null.
  • JSON booleans map to .NET bool values.
  • JSON numbers map to int, long or double.
  • JSON strings map to .NET string values.
  • JSON arrays map to List<object?>.
  • JSON objects map to Dictionary<string, object?>.

Here's a reference implementation of the above:

public class NaturalObjectConverter : JsonConverter<object>
{
    public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => ReadObjectCore(ref reader);

    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
    {
        Type runtimeType = value.GetType();
        if (runtimeType == typeof(object))
        {
            writer.WriteStartObject();
            writer.WriteEndObject();
        }
        else
        {
            JsonSerializer.Serialize(writer, value, runtimeType, options);
        }
    }

    private static object? ReadObjectCore(ref Utf8JsonReader reader)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return null;

            case JsonTokenType.False or JsonTokenType.True:
                return reader.GetBoolean();

            case JsonTokenType.Number:
                if (reader.TryGetInt32(out int intValue))
                {
                    return intValue;
                }
                if (reader.TryGetInt64(out long longValue))
                {
                    return longValue;
                }

                // TODO decimal handling?
                return reader.GetDouble();

            case JsonTokenType.String:
                return reader.GetString();

            case JsonTokenType.StartArray:
                var list = new List<object?>();
                while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
                {
                    object? element = ReadObjectCore(ref reader);
                    list.Add(element);
                }
                return list;

            case JsonTokenType.StartObject:
                var dict = new Dictionary<string, object?>();
                while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
                {
                    Debug.Assert(reader.TokenType is JsonTokenType.PropertyName);
                    string propertyName = reader.GetString()!;

                    if (!reader.Read()) throw new JsonException();
                    object? propertyValue = ReadObjectCore(ref reader);
                    dict[propertyName] = propertyValue;
                }
                return dict;

            default:
                throw new JsonException();
        }
    }
}

The reference implementation is intentionally simplistic and necessarily loses fidelity when it comes to its roundtripping abilities. A few noteworthy examples:

  • Values such as DateTimeOffset, TimeSpan and Guid are not roundtripped, instead users get back the string representation of these values. This is done intentionally for consistency, since such a deserialization scheme cannot support all possible types that serialize to string.
  • Non-standard numeric representations such as NaN, PositiveInfinity and NegativeInfinity currently serialized as strings using the opt-in JsonNumberHandling.AllowNamedFloatingPointLiterals flag are not roundtripped and are instead returned as strings.
  • Numeric values can lose fidelity (e.g. decimal.MaxValue gets fit into a double representation).

API Proposal

namespace System.Text.Json.Serialization;

public enum JsonUnknownTypeHandling
{
    JsonElement,
    JsonNode,
+    DotNetPrimitives,
}

API Usage

var options = new JsonSerializerOptions { UnknownTypeHandling = JsonUnknownTypeHandling.DotNetPrimitives };

var result = JsonSerializer.Deserialize<object>("""[null, 1, 3.14, true]""", options);
Console.WriteLine(result is List<object>); // True
foreach (object? value in (List<object>)result) Console.WriteLine(value?.GetType()); // null, int, double, bool

Alternative Designs

Do nothing, have users write their own custom converters.

Risks

There is no one way in which such a "natural" converter could be implemented and there also is no way in which the implementation could be extended by users. There is a good risk that users will not be able to use the feature because they require that the converter is able to roundtrip DateOnly or Uri instances, in which case they would still need to write a custom converter from scratch.

cc @stephentoub @bartonjs @tannergooding who might have thoughts on how primitives get roundtripped.

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Feb 6, 2024
  2. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Feb 6, 2024
  3. removed
    untriagedNew issue has not been triaged by the area owner
    on Feb 6, 2024
  4. added this to the Future milestone on Feb 6, 2024
  5. RenderMichael commented on Feb 8, 2024

    @RenderMichael
    Contributor

    This would be great. I’ve had to define methods like

    static object ToNormalObject(this JsonNode? value)
    {
        // basically the same implementation as OP
    }

    In my migrations, I’ve had types with properties of Dictionary<string, object?> and due to the limitations described in this issue, a lot of the operations I was doing (mostly casting) went from “just working” to InvalidCastExceptions.

    Another workaround I did was making the property type JsonObject and change all the uses of this property to use the JSON type. I didn’t like doing this because it exposed the implementation detail of serialization. But it’s the hardiest solution to the problem, and actually exposed some bugs 😄.

  6. eiriktsarpalis commented on Feb 8, 2024

    @eiriktsarpalis
    MemberAuthor

    I didn’t like doing this because it exposed the implementation detail of serialization. But it’s the hardiest solution to the problem, and actually exposed some bugs 😄.

    I think that comment is spot on. This functionality is popular simply because it's what Json.NET was doing but it is fundamentally compromised when it comes to round-tripping capability.

  7. RenderMichael commented on Feb 8, 2024

    @RenderMichael
    Contributor

    The biggest problem with fidelity I encountered was numbers being non-roundtrippable. In my opinion the best solution would be to subtype JsonValue further for numbers, and add some numeric methods to this JsonNumber type, ideally even some generic math.

    As a JSON number, it would have to follow JavaScript rules, but I think it can be done.

    That’s a separate issue from deserializing to an object though.

  8. eiriktsarpalis commented on Feb 8, 2024

    @eiriktsarpalis
    MemberAuthor

    I agree that a JsonNumber type would be useful, however substituting it in the existing JsonNode hierarchy would be very much a breaking change.

  9. RenderMichael commented on Feb 8, 2024

    @RenderMichael
    Contributor

    substituting it in the existing JsonNode hierarchy would be very much a breaking change.

    How so? If JsonNumber is a subtype of JsonValue, then I don't see the problem unless there's some GetType() == typeof(JsonValue) shenanigans somewhere.

  10. eiriktsarpalis commented on Feb 8, 2024

    @eiriktsarpalis
    MemberAuthor

    I was incorrectly assuming that JsonValue<T> is part of the public API surface, but it seems like it isn't.

  11. Dreamescaper commented on May 15, 2025

    @Dreamescaper

    This functionality would certainly be useful. I've encountered a requirement to accept a property, which could be either string, or int, or string[], or int[].
    I had to introduce a custom converter, but would be really nice to be able simply to declare it as object.

    I'm willing to contribute it if that helps to bring it in.

  12. eiriktsarpalis commented on Mar 13, 2026

    @eiriktsarpalis
    MemberAuthor

    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.

    [API Proposal]: Add JsonUnknownTypeHandling.Natural for deserializing object to .NET primitives

    Background and motivation

    When deserializing JSON into properties or collections typed as object, STJ currently wraps values in either JsonElement or JsonNode. Users migrating from Json.NET (and many others) expect JSON primitives to map to their natural .NET counterparts — int for integers, string for strings, bool for booleans, etc. This is the most commonly requested object deserialization behavior and a frequent migration pain point.

    Today the workaround is writing a custom JsonConverter<object> that manually reads each token type and decides how to map it. This is non-trivial to get right, particularly around number precision, nested structures, and reference handling.

    // Today: object-typed properties always produce JsonElement
    var doc = JsonSerializer.Deserialize<Dictionary<string, object>>("""{"count": 42, "name": "test"}""");
    Console.WriteLine(doc["count"].GetType()); // System.Text.Json.JsonElement
    Console.WriteLine(doc["count"] is int);    // False

    API Proposal

    namespace System.Text.Json.Serialization;
    
    public partial enum JsonUnknownTypeHandling
    {
        // EXISTING
        // JsonElement = 0,
        // JsonNode = 1,
    
        Natural = 2,
    }

    API Usage

    var options = new JsonSerializerOptions { UnknownTypeHandling = JsonUnknownTypeHandling.Natural };
    
    // Primitive types
    var result = JsonSerializer.Deserialize<object>("""42""", options);
    Console.WriteLine(result is int); // True
    
    result = JsonSerializer.Deserialize<object>("""3.14""", options);
    Console.WriteLine(result is double); // True
    
    result = JsonSerializer.Deserialize<object>(""""hello"""", options);
    Console.WriteLine(result is string); // True
    
    // Arrays become List<object?>
    var array = JsonSerializer.Deserialize<object>("""[1, "two", true]""", options);
    Console.WriteLine(array is List<object?>); // True
    
    // Objects become Dictionary<string, object?>
    var obj = JsonSerializer.Deserialize<object>("""{"name": "test", "count": 42}""", options);
    var dict = (Dictionary<string, object?>)obj!;
    Console.WriteLine(dict["name"] is string); // True
    Console.WriteLine(dict["count"] is int);   // True
    
    // Works with JsonSerializerContext for source generation
    [JsonSourceGenerationOptions(UnknownTypeHandling = JsonUnknownTypeHandling.Natural)]
    [JsonSerializable(typeof(Dictionary<string, object>))]
    partial class MyContext : JsonSerializerContext;

    Type mapping

    JSON token .NET type Notes
    null null
    true / false bool
    Integer number int → long Widened to long if value exceeds int range
    Decimal number double → decimal Falls back to decimal when double loses precision
    String string Default
    String (date) DateTimeOffset If the string matches RFC 3339 / ISO 8601
    String (GUID) Guid If the string matches the D format (xxxxxxxx-xxxx-...)
    Array List<object?> Elements recursively use Natural mapping
    Object Dictionary<string, object?> Values recursively use Natural mapping

    Design decisions

    • List<object?> for arrays: Using a growable list allows consumers to add or remove elements after deserialization. Dictionary<string, object?> is used for objects for the same mutability reason.

    • Number precision cascade (int → long → double → decimal): Integers return the narrowest type that fits. Floating-point numbers return double unless the decimal representation would lose precision, in which case decimal is returned. This matches the expectations of users who round-trip numbers through object.

    • String format detection: Utf8JsonReader already has TryGetDateTimeOffset and TryGetGuid — we reuse these to detect well-known formats. This is limited to types that have TryGet* methods on the reader, so types like TimeSpan, Uri, and Version remain string. Users who need those can provide a custom converter.

    • DateTimeOffset preferred over DateTime: TryGetDateTimeOffset succeeds for all valid ISO 8601 date strings (including those without offsets, using the local timezone). This means the DateTime branch is largely unreachable in practice. This is an intentional simplification — DateTimeOffset is the recommended date type.

    • Duplicate property handling: Respects JsonSerializerOptions.AllowDuplicateProperties. When false (the default for JsonSerializerOptions.Strict), throws JsonException on duplicate keys. When true, last value wins.

    • Read-only converter: Natural only affects deserialization. Serialization of object-typed values delegates to the existing polymorphic serialization.

    Alternative designs

    • DotNetPrimitives name (per [API Proposal]: Add a JsonUnknownTypeHandling setting for deserializing object to .NET primitive values #98038): Natural is shorter and aligns with the "natural type" terminology used in other language ecosystems.

    • List<object?> for arrays: More flexible but adds mutable collection overhead for a fundamentally fixed-size JSON structure.

    • No string format detection: Simpler, but misses the common case where dates and GUIDs stored as strings should roundtrip through object properties without custom converters. The detection uses the reader's built-in parsing which is already validated and efficient.

    Risks

    • Source breaking: None. This adds a new enum value with no impact on existing code.
    • Binary breaking: None.
    • Behavioral: This is opt-in via UnknownTypeHandling = Natural, so no existing behavior changes.
    • Guid false positives: Any string matching the D Guid format will be deserialized as Guid, not string. This could surprise users with strings that happen to look like GUIDs.

    Open questions

    • Should string format detection be opt-in via a separate option, or is the current behavior (always detect DateTimeOffset/Guid) acceptable?
    • Should 0.1 return double (lossy but conventional) or decimal (precise)? The current prototype returns decimal because (decimal)0.1d != 0.1m, but double may be more expected for common floating-point literals.
    • For numeric values exceeding the range of decimal, should the deserializer use a potential JsonNumber primitive type (an opaque wrapper over the raw UTF-8 JSON number text) instead of throwing? This would provide lossless preservation of arbitrary-precision numbers without requiring JsonElement/JsonNode.

    Related issues

    Prototype

    https://github.com/eiriktsarpalis/runtime/compare/prototype/natural-type-handling

    Branch: prototype/natural-type-handling

    • 8 files changed
    • All 50,603 STJ tests pass (net11.0, 0 failures)
    • Multi-model code review completed (Gemini, GPT-5.2, GPT-5.4, Claude Opus 4.6)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions