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 inRead, callreader.Skip()to advance the reader over nested structures safely. - Avoid
JsonDocumentin Performance-Critical Paths: Parsing viaJsonDocument.ParseValue(ref reader)allocates memory. For high-throughput services, use the forward-onlyUtf8JsonReaderstate machine directly. - Preserve State: Ensure that
Utf8JsonWriter.WriteStartObject()andWriteEndObject()pairs are balanced to prevent invalid JSON structure exceptions.