Repository navigation
[API Proposal]: Add a JsonUnknownTypeHandling setting for deserializing object to .NET primitive values #98038
Description
Activity
- addedapi-suggestionEarly API idea and discussion, it is NOT ready for implementationEarly API idea and discussion, it is NOT ready for implementation
on Feb 6, 2024 - ghost addeduntriagedNew issue has not been triaged by the area ownerNew issue has not been triaged by the area owner
on Feb 6, 2024 - removeduntriagedNew issue has not been triaged by the area ownerNew issue has not been triaged by the area owner
on Feb 6, 2024 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” toInvalidCastExceptions.Another workaround I did was making the property type
JsonObjectand 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 😄.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.
The biggest problem with fidelity I encountered was numbers being non-roundtrippable. In my opinion the best solution would be to subtype
JsonValuefurther for numbers, and add some numeric methods to thisJsonNumbertype, 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
objectthough.Reacted by Eirik TsarpalisI agree that a
JsonNumbertype would be useful, however substituting it in the existingJsonNodehierarchy would be very much a breaking change.substituting it in the existing JsonNode hierarchy would be very much a breaking change.
How so? If
JsonNumberis a subtype ofJsonValue, then I don't see the problem unless there's someGetType() == typeof(JsonValue)shenanigans somewhere.I was incorrectly assuming that
JsonValue<T>is part of the public API surface, but it seems like it isn't.Reacted by Michael RenderThis functionality would certainly be useful. I've encountered a requirement to accept a property, which could be either
string, orint, orstring[], orint[].
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.
- marked [API Proposal]: Option to infer CLR type when deserializing JSON primitives as
object#116118 as a duplicate of this issueon Jun 3, 2025 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.Naturalfor deserializingobjectto .NET primitivesBackground and motivation
When deserializing JSON into properties or collections typed as
object, STJ currently wraps values in eitherJsonElementorJsonNode. Users migrating from Json.NET (and many others) expect JSON primitives to map to their natural .NET counterparts —intfor integers,stringfor strings,boolfor booleans, etc. This is the most commonly requestedobjectdeserialization 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 nullnulltrue/falseboolInteger number int→longWidened to longif value exceedsintrangeDecimal number double→decimalFalls back to decimalwhendoubleloses precisionString stringDefault String (date) DateTimeOffsetIf the string matches RFC 3339 / ISO 8601 String (GUID) GuidIf the string matches the Dformat (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 returndoubleunless the decimal representation would lose precision, in which casedecimalis returned. This matches the expectations of users who round-trip numbers throughobject. -
String format detection:
Utf8JsonReaderalready hasTryGetDateTimeOffsetandTryGetGuid— we reuse these to detect well-known formats. This is limited to types that haveTryGet*methods on the reader, so types likeTimeSpan,Uri, andVersionremainstring. Users who need those can provide a custom converter. -
DateTimeOffset preferred over DateTime:
TryGetDateTimeOffsetsucceeds for all valid ISO 8601 date strings (including those without offsets, using the local timezone). This means theDateTimebranch is largely unreachable in practice. This is an intentional simplification —DateTimeOffsetis the recommended date type. -
Duplicate property handling: Respects
JsonSerializerOptions.AllowDuplicateProperties. Whenfalse(the default forJsonSerializerOptions.Strict), throwsJsonExceptionon duplicate keys. Whentrue, last value wins. -
Read-only converter:
Naturalonly affects deserialization. Serialization ofobject-typed values delegates to the existing polymorphic serialization.
Alternative designs
-
DotNetPrimitivesname (per [API Proposal]: Add aJsonUnknownTypeHandlingsetting for deserializingobjectto .NET primitive values #98038):Naturalis 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
objectproperties 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
DGuid format will be deserialized asGuid, notstring. 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.1returndouble(lossy but conventional) ordecimal(precise)? The current prototype returnsdecimalbecause(decimal)0.1d != 0.1m, butdoublemay be more expected for common floating-point literals. - For numeric values exceeding the range of
decimal, should the deserializer use a potentialJsonNumberprimitive 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 requiringJsonElement/JsonNode.
Related issues
- [API Proposal]: Add a
JsonUnknownTypeHandlingsetting for deserializingobjectto .NET primitive values #98038 — Original proposal forJsonUnknownTypeHandling.DotNetPrimitives - System.Text.Json deserializing of object[bool] does not produce boolean #29960 — Discussion on
objectdeserialization behavior
Prototype
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)
Reacted by Oleksandr Liakhevych-
Background and motivation
Branching off from the conversation in #29960 and #97801 to consider a potential built-in
objectdeserializer that targets .NET primitive values as opposed to targeting the DOM types:JsonNodeorJsonElement. The background is enabling users migrating off ofJson.NETneeding a quick way to supportobjectdeserialization, 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:
null.boolvalues.int,longordouble.stringvalues.List<object?>.Dictionary<string, object?>.Here's a reference implementation of the above:
The reference implementation is intentionally simplistic and necessarily loses fidelity when it comes to its roundtripping abilities. A few noteworthy examples:
DateTimeOffset,TimeSpanandGuidare 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.NaN,PositiveInfinityandNegativeInfinitycurrently serialized as strings using the opt-inJsonNumberHandling.AllowNamedFloatingPointLiteralsflag are not roundtripped and are instead returned as strings.decimal.MaxValuegets fit into adoublerepresentation).API Proposal
namespace System.Text.Json.Serialization; public enum JsonUnknownTypeHandling { JsonElement, JsonNode, + DotNetPrimitives, }API Usage
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
DateOnlyorUriinstances, 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.