Custom JsonConverter for Complex Types in C

Custom JsonConverter<T> implementations in .NET provide granular control over how types are serialized and deserialized when standard System.Text.Json conventions are insufficient. This guide covers how to build, register, and optimize custom converters for complex scenarios—such as polymorphic hierarchies, immutable types, and mismatched JSON structures—ensuring reliable and performant data processing in modern C# applications.

Why Use a Custom JsonConverter?

The default serializer handles standard primitive types, collections, and simple POCOs out of the box. However, real-world data often presents challenges:

  • Polymorphic Deserialization: Resolving derived types dynamically based on a discriminator property.
  • Schema Mismatches: Mapping deeply nested JSON properties into flat C# domain entities, or vice versa.
  • Encapsulation & Immutability: Instantiating complex types that lack parameterless constructors and public setters without sacrificing domain invariants.
  • Legacy Formats: Interfacing with external systems that use unconventional serialization rules.

Core Architecture of JsonConverter<T>

To implement a custom converter, derive from JsonConverter<T> where T is the complex type. You must implement two core methods:

  • Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options): Parses JSON tokens into the target C# type.
  • Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options): Writes the C# type out as JSON tokens.

Implementing a Converter for a Complex Type

Consider a scenario where an external service returns a flat JSON payload containing geolocation data, but your domain model enforces a strongly-typed Location object containing a nested Coordinates record:

public record Coordinates(double Latitude, double Longitude);

public class Location
{
    public string Name { get; set; } = string.Empty;
    public Coordinates Position { get; set; } = null!;
}

External JSON:

{
  "location_name": "Headquarters",
  "lat": 37.7749,
  "lng": -122.4194
}

The Custom Converter Implementation

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

public class LocationJsonConverter : JsonConverter<Location>
{
    public override Location Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException("Expected StartObject token.");
        }

        string? name = null;
        double? latitude = null;
        double? longitude = null;

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                break;
            }

            if (reader.TokenType == JsonTokenType.PropertyName)
            {
                string propertyName = reader.GetString()!;
                reader.Read();

                switch (propertyName)
                {
                    case "location_name":
                        name = reader.GetString();
                        break;
                    case "lat":
                        latitude = reader.GetDouble();
                        break;
                    case "lng":
                        longitude = reader.GetDouble();
                        break;
                    default:
                        reader.Skip();
                        break;
                }
            }
        }

        if (name == null || !latitude.HasValue || !longitude.HasValue)
        {
            throw new JsonException("Missing required properties for Location.");
        }

        return new Location
        {
            Name = name,
            Position = new Coordinates(latitude.Value, longitude.Value)
        };
    }

    public override void Write(Utf8JsonWriter writer, Location value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        writer.WriteString("location_name", value.Name);
        writer.WriteNumber("lat", value.Position.Latitude);
        writer.WriteNumber("lng", value.Position.Longitude);

        writer.WriteEndObject();
    }
}

Registering the Converter

A custom converter can be applied in two ways depending on scope.

1. Attribute-Based Registration (Declarative)

Decorate the class directly to enforce conversion rules application-wide:

[JsonConverter(typeof(LocationJsonConverter))]
public class Location
{
    public string Name { get; set; } = string.Empty;
    public Coordinates Position { get; set; } = null!;
}

2. Runtime Registration (Global or Per-Call)

Add the converter directly to JsonSerializerOptions:

var options = new JsonSerializerOptions();
options.Converters.Add(new LocationJsonConverter());

string json = File.ReadAllText("location.json");
Location location = JsonSerializer.Deserialize<Location>(json, options)!;

Handling Polymorphic Types via JsonConverterFactory

When dealing with open generic types or deep inheritance hierarchies (e.g., base classes or interfaces), implement JsonConverterFactory.

A factory inspects the requested type dynamically at runtime, determines whether it can convert it via CanConvert(Type), and generates a typed JsonConverter<T> instance on demand. This approach eliminates repetitive boilerplate when handling complex collections of derived types.

Best Practices

  • Always Handle reader.Skip(): When encountering unexpected properties in Read, call reader.Skip() to advance the reader over nested structures safely.
  • Avoid JsonDocument in Performance-Critical Paths: Parsing via JsonDocument.ParseValue(ref reader) allocates memory. For high-throughput services, use the forward-only Utf8JsonReader state machine directly.
  • Preserve State: Ensure that Utf8JsonWriter.WriteStartObject() and WriteEndObject() pairs are balanced to prevent invalid JSON structure exceptions.