Table of Contents

Untyped JSON and DOM converters

Nerdbank.Json can process JSON without a static model. The centerpiece is a native, dependency-free document object model — JsonValue — plus a set of opt-in converters for the CLR object and ExpandoObject types and the System.Text.Json DOM types.

Everything here is opt-in. If you never call the extension methods below, the default serializer roots no additional dependencies, never touches System.Text.Json, and uses no reflection, so it stays trimming- and NativeAOT-safe.

public static class UntypedJsonSample
{
    // Parse arbitrary JSON into the native, dependency-free DOM. No shape, no reflection,
    // and System.Text.Json is never rooted.
    public static void ParseIntoDom(JsonSerializer serializer)
    {
        JsonValue? value = serializer.DeserializeJsonValue("""{"name":"Ada","scores":[10,9.5,8]}""");

        var root = (JsonObject)value!;
        var name = (JsonString)root["name"];
        var scores = (JsonArray)root["scores"];

        System.Console.WriteLine(name.Value);            // Ada
        System.Console.WriteLine(scores.Count);          // 3

        // JSON number tokens are preserved exactly (e.g. "9.5" stays "9.5").
        System.Console.WriteLine(((JsonNumber)scores[1]).RawToken);

        string json = serializer.SerializeJsonValue(value);
        System.Console.WriteLine(json);
    }

    // Opt in to converters for object / ExpandoObject / JsonValue members.
    public static void ObjectAndExpando()
    {
        JsonSerializer serializer = new JsonSerializer().WithObjectConverter().WithExpandoObjectConverter();

        // A member typed as `object` deserializes to primitives, dictionaries, and arrays.
        object? boxed = serializer.Deserialize<object, Witness>("""[1,2,3]""");
        System.Console.WriteLine(((object?[])boxed!).Length);

        // ExpandoObject round-trips as a JSON object (note: reconstruction is O(n^2)).
        ExpandoObject expando = serializer.Deserialize<ExpandoObject, Witness>("""{"a":1,"b":true}""")!;
        IDictionary<string, object?> members = expando;
        System.Console.WriteLine(members.Count);
    }

    [GenerateShapeFor<object>]
    [GenerateShapeFor<ExpandoObject>]
    private partial class Witness;
}

The native JsonValue DOM

JsonValue is an immutable hierarchy:

  • JsonObject — an ordered, duplicate-checked set of members with O(1) name lookup.
  • JsonArray — an immutable IReadOnlyList<JsonValue>.
  • JsonString, JsonBoolean, JsonNull.
  • JsonNumber — stores the exact JSON number token (RawToken) so values such as 1.0, 1e10, -0, and integers beyond long round-trip byte-for-byte. Convenience accessors (GetInt64, GetDouble, GetDecimal, and their Try* forms) parse on demand.

Parse and write it with the dedicated methods on JsonSerializer: DeserializeJsonValue / SerializeJsonValue (string, UTF-8 bytes, ReadOnlySequence<byte>, or Stream) and their async counterparts DeserializeJsonValueAsync / SerializeJsonValueAsync (Stream or PipeReader/PipeWriter). These require no generated shape at all. Object and array reads are incremental on the async path.

Reads enforce the configured MaxDepth, the new MaxObjectMemberCount property-count limit, duplicate property rejection, and honor AllowTrailingCommas and ReadCommentHandling.

object and ExpandoObject

Call WithObjectConverter to register the converter for object. It reads JSON into boxed primitives, read-only dictionaries with object keys containing strings, and object arrays. Non-negative integer tokens become ulong, negative integers become long, and fractional or exponent-form numbers become double. Numbers outside those ranges fail explicitly; use the native DOM when exact number-token fidelity is required.

Runtime-shaped objects can be written through an object member when their shape is available from TypeShapeProvider. No arbitrary reflection-based discovery is performed. The converter also writes its deserialized dictionary and array representations, native DOM values, and boxed JSON primitives.

Use WithDynamicObjectConverter instead when dictionaries should also support read-only C# dynamic member access. Both registrations target the same object type and cannot be combined on one serializer. The dynamic dictionaries implement IDynamicMetaObjectProvider directly and remain usable as IReadOnlyDictionary<object, object?> in NativeAOT applications. C# dynamic operations themselves require runtime code generation; use dictionary access in NativeAOT.

Call WithExpandoObjectConverter separately to enable ExpandoObject. Its member values use the same primitives and arrays, with nested objects represented by read-only dynamic dictionaries. Reconstructing an expando is O(n²) because each insertion scans its existing members. Both ExpandoObjectMaxPropertyCount and MaxObjectMemberCount bound the property count on reads and writes; the lower limit applies. The default expando-specific limit is 128 properties, while TrustedData removes that limit for trusted input.

Call WithJsonValueConverter to register converters for shaped members declared as JsonValue or any of its concrete subclasses. This preserves the native hierarchy and exact number tokens independently of the object and expando opt-ins. Each registration returns an immutable serializer copy and rejects duplicate converter registrations.

Name/value collections

Call WithNameValueCollectionConverter to enable NameValueCollection. Each JSON property holds null, a single string, or an array of strings, and the configured string converter processes its values. Null collection keys are rejected because JSON property names must be strings.

System.Text.Json interop

Call WithSystemTextJsonConverters to register converters for JsonElement, JsonNode, and JsonDocument. They bridge by round-tripping raw JSON text, so they add no reflection — but calling the method roots the System.Text.Json assembly. Because the converters are registered only when you ask for them, the default serializer keeps System.Text.Json out of the trimmed/NativeAOT closure.

NativeAOT

The native DOM and the object and expando converters avoid reflection-based contract discovery. Supply source-generated shapes for any runtime-shaped objects written through the object converter. The System.Text.Json interop opt-in additionally brings that assembly into the application. Use the native validation fixtures to verify the integrated application; a minimal size report alone does not establish broad NativeAOT compatibility.