Table of Contents

Targeted deserialization

Targeted deserialization reads only a single value selected by a path from a JSON document, skipping everything else. This avoids materializing an entire object graph when you need just one property, array element, or dictionary entry.

[GenerateShape]
public partial record Weather(Location Location, Reading[] Readings);

[GenerateShape]
public partial record Location(string City, string Country);

[GenerateShape]
public partial record Reading(string Kind, double Value);

JsonSerializer serializer = new();
string json =
    """
    {"location":{"city":"Seattle","country":"US"},"readings":[{"kind":"temp","value":21.5},{"kind":"humidity","value":0.42}]}
    """;

// Ergonomic expression overload: parsed but never compiled, so it is NativeAOT-safe.
// CLR member names are translated to serialized names automatically.
string city = serializer.DeserializePath<Weather, string>(json, new JsonSerializer.DeserializePathOptions<Weather, string>(w => w.Location.City))!;

// Pre-parsed path: build once, reuse on hot paths. Names are the serialized (wire) names.
JsonPath secondReadingValue = JsonPath.Root.Member("readings").Index(1).Member("value");
double humidity = serializer.DeserializePath<double, Weather>(json, secondReadingValue);

// A missing path can throw or return the default value.
string? missing = serializer.DeserializePath<Weather, string>(json, new JsonSerializer.DeserializePathOptions<Weather, string>(w => w.Location.Country) { DefaultForUndiscoverablePath = true });

Call DeserializePath with JsonSerializer.DeserializePathOptions<TRoot, TElement> to select a typed expression path. The options carry the root and selected element types and are reusable across calls.

Two ways to specify a path

  • Expression overloads (w => w.Location.City) are ergonomic. The expression is parsed but never compiled, so they remain NativeAOT-safe. CLR member names are translated to serialized names using the configured PropertyNamingPolicy and DictionaryKeyNamingPolicy. Array and dictionary indices must be compile-time constants; unsupported expressions (method calls, dynamic indices) are rejected before any JSON is read.
  • Pre-parsed JsonPath is a low-level, immutable path built from the serialized (wire) names. Build it once and reuse it across many calls to avoid repeated analysis on hot code paths.

Behavior

  • The selected value is deserialized through the full converter graph, so custom converters, union envelopes, and naming policies remain correct for the target subtree.
  • Navigation honors the serializer's case-insensitivity, comment handling, and trailing-comma settings, and works over multi-segment (ReadOnlySequence<byte>) input.
  • A missing path throws JsonPathNotFoundException by default. For typed paths, set DefaultForUndiscoverablePath to return the target's default value instead. Raw JsonPath overloads use MissingPathBehavior for the same choice. An intermediate null is treated as a missing path.

Expression overloads can traverse through intermediate values whose JSON representation is not a plain object or array, because the parser records the converter for each segment's container and the navigator asks that converter how to descend. This works out of the box for:

  • Generated unions and runtime-configured unions, whose $type metadata object is unwrapped so navigation continues into the selected case's payload (base members declared on the union's base type are addressable this way).
  • Duck-typing unions, whose payload is already a plain object.

Custom converters that use a non-standard representation participate by overriding SkipToPropertyValue or SkipToIndexValue. The default implementations navigate self-describing objects and arrays. A converter that remaps logical members, such as one that writes a value as a positional array, translates the supplied property shape or index into its own representation and leaves the reader positioned at the selected value. Return false when the value is absent.

// A custom converter that stores a Point as the positional array [x, y] overrides SkipToPropertyValue so a
// path such as s => s.Position.Y can traverse *through* the converter's representation. Without the
// override the navigator would only understand plain objects and arrays.
public sealed class PointConverter : JsonConverter<Point>
{
    public override void Write(ref JsonWriter writer, in Point? value, SerializationContext context)
    {
        writer.WriteStartArray();
        writer.Write(value!.X);
        writer.WriteValueSeparator();
        writer.Write(value.Y);
        writer.WriteEndArray();
    }

    public override Point? Read(ref JsonReader reader, SerializationContext context)
    {
        reader.ReadStartArray();
        int x = int.Parse(reader.ReadNumberToken(), System.Globalization.CultureInfo.InvariantCulture);
        reader.ReadValueSeparator();
        int y = int.Parse(reader.ReadNumberToken(), System.Globalization.CultureInfo.InvariantCulture);
        reader.ReadEndArray();
        return new Point(x, y);
    }

    public override bool SkipToPropertyValue(ref JsonReader reader, PolyType.Abstractions.IPropertyShape propertyShape, SerializationContext context)
    {
        int index =
            propertyShape.Name == "X" ? 0 :
            propertyShape.Name == "Y" ? 1 : -1;
        if (index < 0)
        {
            return false;
        }

        reader.ReadStartArray();
        if (reader.TryReadEndArray())
        {
            return false;
        }

        for (int i = 0; i < index; i++)
        {
            reader.Skip(context);
            if (reader.TryReadEndArray())
            {
                return false;
            }

            reader.ReadValueSeparator();
        }

        return true;
    }
}

For streaming paths, override SkipToPropertyValueAsync or SkipToIndexValueAsync as appropriate. Use the asynchronous reader's structural methods to enter containers so the navigator can drain the enclosing envelope correctly.

Pre-parsed JsonPath navigation uses raw JSON tokens only. It still targets any type, and it traverses plain objects and arrays, but it cannot descend through a union envelope or a remapping converter (the wire alone does not carry that metadata). In that case a clear NotSupportedException is thrown that points you to the expression-based overload, which supplies the converter metadata required to traverse the value.

Limitations

  • Accessing a member declared only on a derived union case (via a cast in the expression) is not supported; only members visible on the static type of each segment are addressable.
  • Targeted deserialization is not supported when PreserveReferences is enabled, because reference metadata wraps every value; a clear NotSupportedException is thrown.