Custom Converters and Source Generation
Write a JsonConverter when the default JSON shape is wrong, and use the source generator to remove reflection for trimming and AOT.
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)The serializer guesses a JSON shape from your type. Sometimes that guess is wrong, and sometimes the way it makes the guess is the problem. Each case has its own fix.
A converter changes the shape
A converter is a class you write that decides exactly how one type is read and written. Derive from JsonConverter<T> and override two methods. Read takes ref Utf8JsonReader reader, a Type typeToConvert and the JsonSerializerOptions, and returns a value. Write takes a Utf8JsonWriter, the value and the options, and returns nothing. The reader is positioned on the first token of your value, and your Read must leave it on the last one.
Register a converter in either of two ways, which do the same thing: add it to options.Converters, or put [JsonConverter(typeof(MyConverter))] on the type or the property.
Source generation removes the reflection
By default the serializer inspects your type at run time to find its properties. That inspection is called reflection. It costs start-up time, and a trimmer cannot see through it, so publishing with trimming or Native AOT can break serialization in ways that only appear when the trimmed program runs.
The source generator moves that work to compile time. Declare a partial class deriving from JsonSerializerContext, mark it with [JsonSerializable(typeof(Book))] for each type, and the generator writes a Default.Book property holding the metadata. Pass that to Serialize or Deserialize, or assign Default to options.TypeInfoResolver. This is what makes JSON work under AOT.
Set the MSBuild property JsonSerializerIsReflectionEnabledByDefault to false to turn a silent trimming failure into a clear exception at development time.
Example
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
JsonSerializerOptions options = new JsonSerializerOptions();
options.Converters.Add(new OrderIdConverter());
Order order = new Order { Id = new OrderId(7421), Total = 19.5m };
string json = JsonSerializer.Serialize(order, options);
Console.WriteLine(json);
Order? back = JsonSerializer.Deserialize<Order>(json, options);
Console.WriteLine(back?.Id.Value);
// Without the converter the wrapper becomes a nested object.
Console.WriteLine(JsonSerializer.Serialize(order));
public readonly record struct OrderId(int Value);
public class Order
{
public OrderId Id { get; set; }
public decimal Total { get; set; }
}
public sealed class OrderIdConverter : JsonConverter<OrderId>
{
public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new OrderId(reader.GetInt32());
}
public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.Value);
}
}
// Output:
// {"Id":7421,"Total":19.5}
// 7421
// {"Id":{"Value":7421},"Total":19.5}When to use it
- Serialize a wrapper type as a plain number instead of a nested object.
- Keep reading an old payload shape while writing the new one.
- Publish a trimmed or Native AOT service that still serializes JSON.
- Cut start-up time in a service that serializes the same few types constantly.
More examples
A converter that accepts two incoming shapes
This is the common real reason to write a converter: a producer changed its mind and you must read both the old and the new payload while always writing the new one. Checking reader.TokenType is how you branch on what actually arrived, and parsing with CultureInfo.InvariantCulture stops a machine configured for a comma decimal separator from reading the number differently.
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
// Older messages sent the number as a string. Both must still load.
Console.WriteLine(JsonSerializer.Deserialize<Reading>("""{"Temp":21}""")?.Temp.Degrees);
Console.WriteLine(JsonSerializer.Deserialize<Reading>("""{"Temp":"21"}""")?.Temp.Degrees);
Console.WriteLine(JsonSerializer.Serialize(new Reading { Temp = new Temperature(21) }));
public readonly record struct Temperature(int Degrees);
public class Reading
{
// The attribute registers the converter for this member only.
[JsonConverter(typeof(TemperatureConverter))]
public Temperature Temp { get; set; }
}
public sealed class TemperatureConverter : JsonConverter<Temperature>
{
public override Temperature Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
string text = reader.GetString() ?? "0";
return new Temperature(int.Parse(text, CultureInfo.InvariantCulture));
}
return new Temperature(reader.GetInt32());
}
public override void Write(Utf8JsonWriter writer, Temperature value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.Degrees);
}
}
// Output:
// 21
// 21
// {"Temp":21}A source-generated context
The class must be partial, because the generator adds the other half. Options go on the JsonSourceGenerationOptions attribute rather than in a JsonSerializerOptions object, so they are fixed at compile time and the generated Default property already has them applied. Note that the generated metadata property is named after the type, and JsonSerializable can be repeated for every type the program serializes.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
// The generator writes the serializing code at compile time, so nothing
// has to inspect Measurement at run time.
Measurement reading = new Measurement { SensorId = "kiln-2", Celsius = 912 };
string json = JsonSerializer.Serialize(reading, AppJson.Default.Measurement);
Console.WriteLine(json);
Measurement? back = JsonSerializer.Deserialize(json, AppJson.Default.Measurement);
Console.WriteLine(back?.SensorId);
public class Measurement
{
public string SensorId { get; set; } = "";
public int Celsius { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Measurement))]
public partial class AppJson : JsonSerializerContext
{
}
// Output:
// {"sensorId":"kiln-2","celsius":912}
// kiln-2Two ways to use the generated metadata
The two calls produce the same JSON by the same generated code. The first form is the clearest at a call site. The second is what you need when existing code already passes options around, or when a framework asks for an options object; ASP.NET Core takes the same approach through its TypeInfoResolverChain. Either way no reflection is involved, which is what trimming and Native AOT require.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
// Shorthand: hand over the metadata for one type.
Console.WriteLine(JsonSerializer.Serialize(new Point { X = 1, Y = 2 }, PointJson.Default.Point));
// Longhand: put the whole context in an options object and keep the generic call.
JsonSerializerOptions options = new JsonSerializerOptions
{
TypeInfoResolver = PointJson.Default
};
Console.WriteLine(JsonSerializer.Serialize(new Point { X = 1, Y = 2 }, options));
public class Point
{
public int X { get; set; }
public int Y { get; set; }
}
[JsonSerializable(typeof(Point))]
public partial class PointJson : JsonSerializerContext
{
}
// Output:
// {"X":1,"Y":2}
// {"X":1,"Y":2}
Discussion