JSON Converters, Contract Customization and Schema Export
Objective
Attributes and options cover most JSON shapes. When they do not, System.Text.Json
gives you three escalating tools: a JsonConverter<T> that writes and reads one
type by hand, contract customization that edits the metadata the serializer built
for a type (without owning the type), and JsonSchemaExporter, which turns that
same contract into a JSON Schema document. This concept covers when each fits,
the rules that bite (null handling, registration precedence, a reader that
consumes the wrong number of tokens), and what the schema exporter says about a
type, including what it cannot know when a custom converter is involved. The
basics of options are in the serialization and options concept.
Use Cases
- Writing a value object (
Money,EmailAddress) as one JSON string instead of an object. - Hiding every property whose name starts with
Internalfrom a model you cannot annotate. - Using a non-string type as a dictionary key.
- Publishing a JSON Schema for a request or a configuration type, for docs, validation or tooling.
Deep Dive
A converter for one type
Derive from JsonConverter<T>, then override Read and Write. All results
below are from .NET 10.0.0 (SDK 10.0.100), macOS.
csharpclass MoneyConverter : JsonConverter<Money>
{
public override Money Read(ref Utf8JsonReader r, Type t, JsonSerializerOptions o)
{
var parts = r.GetString()!.Split(' '); // "12.5 EUR"
return new Money(decimal.Parse(parts[0], CultureInfo.InvariantCulture), parts[1]);
}
public override void Write(Utf8JsonWriter w, Money m, JsonSerializerOptions o)
=> w.WriteStringValue(string.Create(CultureInfo.InvariantCulture, $"{m.Amount} {m.Currency}"));
}
// {"Id":"A-1","Total":"12.5 EUR","Lines":["1 USD"]}The converter applied to Total and to each element of the Money[] array, since
the serializer picks a converter per type. Throwing JsonException from Read
gives the caller a normal serialization error with a path (Bad money 'oops').
Pass CultureInfo.InvariantCulture to both sides: the default ToString on my
machine printed 12,5.
Registration has a fixed precedence, per the docs: [JsonConverter] on a
property wins, then a converter in options.Converters, then [JsonConverter] on
the type. If several custom converters for one type are in the collection, the
first whose CanConvert returns true is used. I checked the middle rule: a Temp
record with [JsonConverter(typeof(TempConverter))] on the type was written as
"from options" when a different converter for it sat in Converters. A
property-level [JsonConverter(typeof(Lower))] beat the options converter on the
same string type ("lower:a" against "B" for the neighbor).
Nulls skip your converter unless you ask
By default a converter is not called for null on reference types (the docs say to
override HandleNull to change that). With a converter that did not override it,
{"Value":null} was written and read back as null without Read or Write ever
running. With HandleNull => true, a null string was written as the "(none)" my
converter chose, and Read received a Null token and returned <null>. So
decide: if a null should become a sentinel, opt in and check TokenType in Read;
otherwise leave it. Note that the property-level converter in the previous test did
not set HandleNull, and its null stayed null.
Readers must consume exactly one value
A converter that returns without consuming its whole value breaks the parse. My
Read called r.Read() once while the value was a nested object, and the
serializer threw JsonException: The converter 'Bad' read too much or not enough
with the path $.V. For an object, the converter must stop on its EndObject; for
a value like a string, on that token. When you delegate to the serializer, use
JsonSerializer.Deserialize<T>(ref reader, options), which advances correctly.
Factories for generic types
A converter for Wrapper<T> cannot be one class, because the code differs per T.
JsonConverterFactory answers CanConvert for the open generic and builds the
closed converter on demand:
csharppublic override bool CanConvert(Type t) => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Wrapper<>);
public override JsonConverter CreateConverter(Type t, JsonSerializerOptions o)
=> (JsonConverter)Activator.CreateInstance(typeof(Inner<>).MakeGenericType(t.GetGenericArguments()[0]))!;
// Wrapper<int>(5) -> 5 Wrapper<string>("s") -> "s"The docs say this factory pattern is required for open generics in .NET 10 and
earlier, and that .NET 11 lets [JsonConverter] take an open generic converter
directly. I only ran .NET 10. Note that Activator and MakeGenericType are
reflection, so under Native AOT the closed types must be known at compile time.
For dictionary keys, Dictionary<Point, int> threw NotSupportedException ("The
type 'Point' is not a supported dictionary key"). Overriding ReadAsPropertyName
and WriteAsPropertyName on a JsonConverter<Point> fixed it: it wrote
{"1,2":5} and read the key back as Point { X = 1, Y = 2 }.
Contract customization: edit the metadata, not the type
Starting in .NET 7 (per the docs) the serializer builds a JsonTypeInfo per type,
and a resolver can change it before use. With DefaultJsonTypeInfoResolver you add
modifiers, which are Action<JsonTypeInfo> callbacks run in order:
csharpvar resolver = new DefaultJsonTypeInfoResolver();
resolver.Modifiers.Add(ti =>
{
if (ti.Kind != JsonTypeInfoKind.Object) return;
foreach (var p in ti.Properties.ToList())
if (p.Name.StartsWith("Internal", StringComparison.Ordinal)) ti.Properties.Remove(p);
});
// Customer("ana","secret",3) -> {"Name":"ana","Visits":3,"display":"ANA"}In my run the same modifier also added a computed display property with
ti.CreateJsonPropertyInfo(typeof(string), "display") and a Get delegate. Other
verified levers: JsonPropertyInfo.ShouldSerialize (a Visits of 0 was omitted
and 2 was written), Set for deserialization, and JsonTypeInfo.NumberHandling.
Check Kind first: per the docs, Object allows the most; types with a custom
converter report None and have nothing to edit. Because the modifier works on
names and types, one rule covers every model, including ones from another assembly.
The contract freezes on first use. After one serialize call on an options instance,
assigning TypeInfoResolver threw InvalidOperationException ("read-only or has
already been used"), and adding to resolver.Modifiers afterwards threw as well.
Build the resolver completely, then create the options.
JsonSchemaExporter
JsonSchemaExporter (introduced in .NET 9, per the docs) describes the contract the
serializer would use. options.GetJsonSchemaAsNode(typeof(Order)) returned for a
record with int Id, string? Note, decimal Total, an enum and a List<string>:
integer, nullable string, number, "integer" for the enum, an array of nullable
strings, and "format": "date-time" for the DateTime. The required list held
every constructor parameter that had no default, nullable or not.
The schema follows your options, which is the point:
- With
JsonSerializerDefaults.Webthe keys becameid,noteand so on, andidturned into["string","integer"]with apattern, because Web defaults allow reading numbers from strings. - Adding
JsonStringEnumConverterchanged the enum to{"enum":["New","Shipped"]}. - The root was
["object","null"]andTagsitems were nullable; settingTreatNullObliviousAsNonNullable = truemade the root"object"and the items"string", whileNotestayed nullable because it is annotated. TransformSchemaNoderuns on every node. ReadingDescriptionAttributefromcontext.PropertyInfo.AttributeProviderput"description": "Order key"onid.
Two traps. A new options instance with no resolver threw InvalidOperationException
("must specify a TypeInfoResolver setting before being marked as read-only"), so set
TypeInfoResolver (for example new DefaultJsonTypeInfoResolver()) or use a source
generation context. And a custom converter hides the shape: for the Invoice using
my MoneyConverter, the schema said "Total": true (anything) and "Lines": {"type":"array"} with no items. The exporter cannot see inside code, so give such
types a schema in TransformSchemaNode.
Trade-offs
- A converter is total control with total responsibility. You own nulls, errors
and cursor position.csharp
throw new JsonException($"Bad money '{s}'"); // gives the caller a path, not a crash - Converters hide the type from the tooling. The schema exporter reported
trueforMoney; a modifier that edits properties cannot touch it either. - Contract modifiers scale better than attributes for cross-cutting rules
(hide
Internal*), but they run per type at first use and needKindchecks. - Frozen options mean ordering matters. Build resolver and modifiers first; changing them after the first call throws.
- A schema describes the serializer's contract, not business rules. It has
types, nullability and
required; it does not know thatTotalmust be positive unless you add that in a transform. - Reflection in factories costs AOT.
MakeGenericTypeneeds the closed types known ahead of time; prefer the source-generated path for AOT apps.