In .NET 7, our focus for System.Text.Json has been to substantially improve extensibility of the library, adding new performance-oriented features and addressing high impact reliability and consistency issues. More specifically, .NET 7 sees the release of contract customization, which gives you more control over how types are serialized or deserialized, polymorphic serialization for user-defined type hierarchies, required member support, and much more.
Getting the latest bits
You can try out the new features by using the latest build of System.Text.Json NuGet package or the latest SDK for .NET 7, which is currently RC.
Contract Customization
System.Text.Json determines how a given .NET type is meant to be serialized and deserialized by constructing a JSON contract for that type. The contract is derived from the type’s shape — such as its available constructors, properties and fields, and whether it implements IEnumerable
or IDictionary
— either at runtime using reflection or at compile time using the source generator. In previous releases, users were able to make limited adjustments to the derived contract using System.Text.Json attribute annotations, assuming they are able to modify the type declaration.
The contract metadata for a given type T
is represented using JsonTypeInfo<T>
, which in previous versions served as an opaque token used exclusively in source generator APIs. Starting in .NET 7, most facets of the JsonTypeInfo
contract metadata have been exposed and made user-modifiable. Contract customization allows users to write their own JSON contract resolution logic using implementations of the IJsonTypeInfoResolver
interface:
public interface IJsonTypeInfoResolver
{
JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options);
}
A contract resolver returns a configured JsonTypeInfo
instance for the given Type
and JsonSerializerOptions
combination. It can return null
if the resolver does not support metadata for the specified input type.
Contract resolution performed by the default, reflection-based serializer is now exposed via the DefaultJsonTypeInfoResolver class, which implements IJsonTypeInfoResolver
. This class lets users extend the default reflection-based resolution with custom modifications or combine it with other resolvers (such as source-generated resolvers).
Starting from .NET 7 the JsonSerializerContext class used in source generation also implements IJsonTypeInfoResolver
. To learn more about the source generator, see How to use source generation in System.Text.Json.
A JsonSerializerOptions
instance can be configured with a custom resolver using the new TypeInfoResolver
property:
// configure to use reflection contracts
var reflectionOptions = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};
// configure to use source generated contracts
var sourceGenOptions = new JsonSerializerOptions
{
TypeInfoResolver = EntryContext.Default
};
[JsonSerializable(typeof(MyPoco))]
public partial class EntryContext : JsonSerializerContext { }
Modifying JSON contracts
The DefaultJsonTypeInfoResolver
class is designed with contract customization in mind and can be extended in a couple of ways:
- Inheriting from the class, overriding the
GetTypeInfo
method, or - Using the
Modifiers
property to subscribe delegates that modify the defaultJsonTypeInfo
results.
Here’s how you could define a custom contract resolver that uses uppercase JSON properties, first using inheritance:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new UpperCasePropertyContractResolver()
};
JsonSerializer.Serialize(new { value = 42 }, options); // {"VALUE":42}
public class UpperCasePropertyContractResolver : DefaultJsonTypeInfoResolver
{
public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
JsonTypeInfo typeInfo = base.GetTypeInfo(type, options);
if (typeInfo.Kind == JsonTypeInfoKind.Object)
{
foreach (JsonPropertyInfo property in typeInfo.Properties)
{
property.Name = property.Name.ToUpperInvariant();
}
}
return typeInfo;
}
}
and the same implementation using the Modifiers
property:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { UseUppercasePropertyNames }
}
};
JsonSerializer.Serialize(new { value = 42 }, options); // {"VALUE":42}
static void UseUppercasePropertyNames(JsonTypeInfo jsonTypeInfo)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (JsonPropertyInfo property in typeInfo.Properties)
{
property.Name = property.Name.ToUpperInvariant();
}
}
Each DefaultJsonTypeInfoResolver
can register multiple modifier delegates, which it runs sequentially on metadata resolved for each type:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { Modifier1, Modifier2, Modifier3 }
}
};
JsonTypeInfo typeInfo = options.GetTypeInfo(typeof(string)); // Prints messages in sequence
static void Modifier1(JsonTypeInfo typeInfo) { Console.WriteLine("Runs first"); }
static void Modifier2(JsonTypeInfo typeInfo) { Console.WriteLine("Runs second"); }
static void Modifier3(JsonTypeInfo typeInfo) { Console.WriteLine("Runs third"); }
Modifier syntax affords a more concise and compositional API compared to inheritance. As such, subsequent examples will focus on that approach.
Notes on authoring contract modifiers
The DefaultJsonTypeInfoResolver.Modifiers
property allows developers to specify a series of handlers to update the metadata contract for all types in the serialization type closure. Modifiers are consulted in the order that they were specified in the DefaultJsonTypeInfoResolver.Modifiers
list. This makes it possible for modifiers to make changes that conflict with each other, possibly resulting in unintended serialization contracts.
For example, consider how the UseUppercasePropertyNames
modifier above would interact with a modifier that filters out properties that are only used in a hypothetical “v0” version of a serialization schema:
JsonSerializerOptions options0 = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { ExcludeV0Members, UseUppercasePropertyNames }
}
};
JsonSerializerOptions options1 = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { UseUppercasePropertyNames, ExcludeV0Members }
}
};
Employee employee = FetchEmployee();
JsonSerializer.Serialize(employee, options0); // {"NAME":"Jane Doe","ROLE":"Contractor"}
JsonSerializer.Serialize(employee, options1); // {"NAME":"Jane Doe","ROLE":"Contractor","ROLE_V0":"Temp"}
static void ExcludeV0Members(JsonTypeInfo jsonTypeInfo)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (JsonPropertyInfo property in typeInfo.Properties)
{
if (property.Name.EndsWith("_v0"))
{
property.ShouldSerialize = false;
}
}
}
class Employee
{
public string Name { get; set; }
public Role Role { get; set; }
public string Role_v0 => { get; set; }
}
Even though not always possible, it is a good idea to design modifiers with composability in mind. One way to do this is by ensuring that any modifications to the contract are append-only, rather than replacing or removing work done by previous modifiers. Consider the following example, where we define a modifier that appends a property with diagnostic information for every object. Such a modifier might want to confirm that a “Data” property does not already exist on a given type:
static void AddDiagnosticDataProperty(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind == JsonTypeInfoKind.Object &&
typeInfo.Properties.All(prop => prop.Name != "Data"))
{
JsonPropertyInfo propertyInfo = typeInfo.CreateJsonPropertyInfo(string, "Data");
propertyInfo.Get = obj => GetDiagnosticData(obj);
typeInfo.Properties.Add(propertyInfo);
}
}
Example: Custom Attribute Support
Contract customization makes it possible to add support for attributes that are not inbox to System.Text.Json
. Here is an example implementing support for DataContractSerializer
‘s IgnoreDataMemberAttribute
:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { DetectIgnoreDataMemberAttribute }
}
};
JsonSerializer.Serialize(new MyPoco(), options); // {"Value":3}
static void DetectIgnoreDataMemberAttribute(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (JsonPropertyInfo propertyInfo in typeInfo.Properties)
{
if (propertyInfo.AttributeProvider is ICustomAttributeProvider provider &&
provider.IsDefined(typeof(IgnoreDataMemberAttribute), inherit: true))
{
// Disable both serialization and deserialization
// by unsetting getter and setter delegates
propertyInfo.Get = null;
propertyInfo.Set = null;
}
}
}
public class MyPoco
{
[JsonIgnore]
public int JsonIgnoreValue { get; } = 1;
[IgnoreDataMember]
public int IgnoreDataMemberValue { get; } = 2;
public int Value { get; } = 3;
}
Example: Conditional Serialization
You can use the JsonPropertyInfo.ShouldSerialize
delegate to determine dynamically whether a given property value should be serialized:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { IgnoreNegativeValues }
}
};
JsonSerializer.Serialize(new MyPoco { IgnoreNegativeValues = false, Value = -1 }, options); // {"Value":-1}
JsonSerializer.Serialize(new MyPoco { IgnoreNegativeValues = true, Value = -1 }, options); // {}
static void IgnoreNegativeValues(JsonTypeInfo typeInfo)
{
if (typeInfo.Type != typeof(MyPoco))
return;
foreach (JsonPropertyInfo propertyInfo in typeInfo.Properties)
{
if (propertyInfo.PropertyType == typeof(int))
{
propertyInfo.ShouldSerialize = static (obj, value) =>
(int)value! >= 0 || !((MyPoco)obj).IgnoreNegativeValues;
}
}
}
public class MyPoco
{
[JsonIgnore]
public bool IgnoreNegativeValues { get; set; }
public int Value { get; set; }
}
Example: Serializing private fields
System.Text.Json does not support private field serialization, as doing that is generally not recommended. However, if really necessary it’s possible to write a contract resolver that only serializes the fields of a given type:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { SerializeObjectFields }
}
};
JsonSerializer.Serialize(new { value = 42 }, options); // {"\u003Cvalue\u003Ei__Field":42}
static void SerializeObjectFields(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
// Remove any properties included by the default resolver
typeInfo.Properties.Clear();
const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (FieldInfo fieldInfo in typeInfo.Type.GetFields(Flags))
{
JsonPropertyInfo propertyInfo = typeInfo.CreateJsonPropertyInfo(fieldInfo.FieldType, fieldInfo.Name);
propertyInfo.Get = obj => fieldInfo.GetValue(obj);
propertyInfo.Set = (obj, value) => fieldInfo.SetValue(obj, value);
typeInfo.Properties.Add(propertyInfo);
}
}
Although as the serialized output would suggest, serializing private fields can be very brittle and error prone. This example has been shared for illustrative purposes only and is not recommended for most real-word applications.
Combining resolvers
It is possible to combine contracts from multiple sources using the JsonTypeInfoResolver.Combine
method. This is commonly applicable to source generated JsonSerializerContext
instances that can only generate contracts for a restricted subset of types:
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(ContextA.Default, ContextB.Default)
};
// Successfully handles serialization for both types
JsonSerializer.Serialize(new PocoA(), options);
JsonSerializer.Serialize(new PocoB(), options);
// Types from Library A
[JsonSerializable(typeof(PocoA))]
public partial class ContextA : JsonSerializerContext { }
public class PocoA { }
// Types from Library B
[JsonSerializable(typeof(PocoB))]
public partial class ContextB : JsonSerializerContext { }
public class PocoB { }
The Combine
method produces a contract resolver that sequentially queries each of its constituent resolvers in order of definition, returning the first result that is not null
. The method supports chaining arbitrarily many resolvers, including DefaultJsonTypeInfoResolver
:
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
ContextA.Default,
ContextB.Default,
new DefaultJsonTypeInfoResolver())
};
// Uses reflection for the outer class, source gen for the property types
JsonSerializer.Serialize(new { A = new PocoA(), B = new PocoB() } , options);
Metadata Kinds
JsonTypeInfo
metadata should be thought of as configuration objects for System.Text.Json’s built-in converters. As such, what metadata is configurable on each type is largely dependent on the underlying converter being used for the type: types using the built-in converter for objects can configure property metadata, whereas types using custom user-defined converters cannot be configured at all. What can be configured is determined by the value of the JsonTypeInfo.Kind
property. There are currently four kinds of metadata available to the user:
Object
– type is serialized as a JSON object usingJsonPropertyInfo
metadata; used for mostclass
orstruct
types by default.Enumerable
– type is serialized as a JSON array; used for most types implementingIEnumerable
.Dictionary
– type is serialized as a JSON object; used for most dictionary types or collections of key/value pairs.None
– type uses a converter not configurable by metadata; typically applies to primitive types,object
,string
or types using custom user-defined converters.
Currently, the Object
kind offers the most configurability, and we plan to add more functionality for Enumerable
and Dictionary
kinds in future releases.
Type Hierarchies
System.Text.Json now supports polymorphic serialization and deserialization of user-defined type hierarchies. This can be enabled by decorating the base class of a type hierarchy with the new JsonDerivedTypeAttribute
:
[JsonDerivedType(typeof(Derived))]
public class Base
{
public int X { get; set; }
}
public class Derived : Base
{
public int Y { get; set; }
}
This configuration enables polymorphic serialization for Base
, specifically when the run-time type is Derived
:
Base value = new Derived();
JsonSerializer.Serialize<Base>(value); // { "X" : 0, "Y" : 0 }
Note that this does not enable polymorphic deserialization since the payload would be round tripped as Base
:
Base value = JsonSerializer.Deserialize<Base>(@"{ ""X"" : 0, ""Y"" : 0 }");
value is Derived; // false
Using Type Discriminators
To enable polymorphic deserialization, you need to specify a type discriminator for the derived class:
[JsonDerivedType(typeof(Base), typeDiscriminator: "base")]
[JsonDerivedType(typeof(Derived), typeDiscriminator: "derived")]
public class Base
{
public int X { get; set; }
}
public class Derived : Base
{
public int Y { get; set; }
}
Now, type discriminator metadata is emitted in the JSON:
Base value = new Derived();
JsonSerializer.Serialize<Base>(value); // { "$type" : "derived", "X" : 0, "Y" : 0 }
The presence of the metadata enables the value to be polymorphically deserialized:
Base value = JsonSerializer.Deserialize<Base>(@"{ ""$type"" : ""derived"", ""X"" : 0, ""Y"" : 0 }");
value is Derived; // true
Type discriminator identifiers can also be integers, so the following form is valid:
[JsonDerivedType(typeof(Derived1), 0)]
[JsonDerivedType(typeof(Derived2), 1)]
[JsonDerivedType(typeof(Derived3), 2)]
public class Base { }
JsonSerializer.Serialize<Base>(new Derived2()); // { "$type" : 1, ... }
Configuring Polymorphism
You can tweak aspects of how polymorphic serialization works using the JsonPolymorphicAttribute
. The following example changes the property name of the type discriminator:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$case")]
[JsonDerivedType(typeof(Derived), "derived")]
public class Base { }
JsonSerializer.Serialize<Base>(new Derived2()); // { "$case" : "derived", ... }
The JsonPolymorphicAttribute
exposes a number of configuration parameters, including properties controlling how undeclared runtime types or type discriminators should be handled on serialization and deserialization, respectively.
Polymorphism using Contract Customization
The JsonTypeInfo
contract model exposes the PolymorphismOptions
property that can be used to programmatically control all configuration of a given type hierarchy:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
static typeInfo =>
{
if (typeInfo.Type != typeof(Base))
return;
typeInfo.PolymorphismOptions = new()
{
TypeDiscriminatorPropertyName = "__type",
DerivedTypes =
{
new JsonDerivedType(typeof(Derived), "__derived")
}
};
}
}
}
};
JsonSerializer.Serialize<Base>(new Derived(), options); // {"__type":"__derived", ... }
Required Members
C# 11 adds support for required
members, a language feature that lets class authors specify properties or fields that must be populated on instantiation. Starting in .NET 7, the reflection serializer in System.Text.Json includes support for required
members: if the member of a deserialized type is marked required
and cannot bind to any property from the JSON payload, deserialization will fail with an exception:
using System.Text.Json;
JsonSerializer.Deserialize<Person>("""{"Age": 42}"""); // throws JsonException
public class Person
{
public required string Name { get; set; }
public int Age { get; set; }
}
It should be noted that required
properties are currently not supported by the source generator. If you’re using the source generator, an earlier C# version, a different .NET language like F# or Visual Basic, or simply need to avoid the required
keyword, you can alternatively use the JsonRequiredAttribute
to achieve the same effect.
using System.Text.Json;
JsonSerializer.Deserialize("""{"Age": 42}""", MyContext.Default.Person); // throws JsonException
[JsonSerializable(typeof(Person))]
public partial class MyContext : JsonSerializerContext { }
public class Person
{
[JsonRequired]
public string Name { get; set; }
public int Age { get; set; }
}
It is also possible to control whether a property is required via the contract model using the JsonPropertyInfo.IsRequired
property:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
static typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (JsonPropertyInfo propertyInfo in typeInfo.Properties)
{
// strip IsRequired constraint from every property
propertyInfo.IsRequired = false;
}
}
}
}
};
JsonSerializer.Deserialize<Person>("""{"Age": 42}""", options); // serialization now succeeds
JsonSerializerOptions.Default
System.Text.Json maintains a default instance of JsonSerializerOptions
to be used in cases where no JsonSerializerOptions
argument has been passed by the user. This (read-only) instance can now be accessed by users via the JsonSerializerOptions.Default
static property. It can be useful in cases where users need to query the default JsonTypeInfo
or JsonConverter
for a given type:
public class MyCustomConverter : JsonConverter<int>
{
private readonly static JsonConverter<int> s_defaultConverter =
(JsonConverter<int>)JsonSerializerOptions.Default.GetConverter(typeof(int));
// custom serialization logic
public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
{
return writer.WriteStringValue(value.ToString());
}
// fall back to default deserialization logic
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return s_defaultConverter.Read(ref reader, typeToConvert, options);
}
}
Or in cases where obtaining a JsonSerializerOptions
is required:
class MyClass
{
private readonly JsonSerializerOptions _options;
public MyClass(JsonSerializerOptions? options = null) => _options = options ?? JsonSerializerOptions.Default;
}
Utf8JsonReader.CopyString
Until today, Utf8JsonReader.GetString()
has been the only way users could consume decoded JSON strings. This will always allocate a new string, which might be unsuitable for certain performance-sensitive applications. The newly included CopyString
methods allow copying the unescaped UTF-8 or UTF-16 strings to a buffer owned by the user:
int valueLength = reader.HasReadOnlySequence
? checked((int)ValueSequence.Length)
: ValueSpan.Length;
char[] buffer = ArrayPool<char>.Shared.Rent(valueLength);
int charsRead = reader.CopyString(buffer);
ReadOnlySpan<char> source = buffer.Slice(0, charsRead);
ParseUnescapedString(source); // handle the unescaped JSON string
ArrayPool<char>.Shared.Return(buffer);
Or if handling UTF-8 is preferable:
ReadOnlySpan<byte> source = stackalloc byte[0];
if (!reader.HasReadOnlySequence && !reader.ValueIsEscaped)
{
// No need to copy to an intermediate buffer if value is span without escape sequences
source = reader.ValueSpan;
}
else
{
int valueLength = reader.HasReadOnlySequence
? checked((int)ValueSequence.Length)
: ValueSpan.Length;
Span<byte> buffer = valueLength <= 256 ? stackalloc byte[256] : new byte[valueLength];
int bytesRead = reader.CopyString(buffer);
source = buffer.Slice(0, bytesRead);
}
ParseUnescapedBytes(source);
Source generation improvements
The System.Text.Json source generator now adds built-in support for the following types:
The following example now works as expected:
Stream stdout = Console.OpenStandardOutput();
MyPoco value = new MyPoco();
await JsonSerializer.SerializeAsync(stdout, value, MyContext.Default.MyPoco);
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(MyPoco))]
public partial class MyContext : JsonSerializerContext
{
}
public class MyPoco
{
public IAsyncEnumerable<DateOnly> Dates => GetDatesAsync();
private async IAsyncEnumerable<DateOnly> GetDatesAsync()
{
DateOnly date = DateOnly.Parse("2022-09-01", CultureInfo.InvariantCulture);
for (int i = 0; i < 10; i++)
{
await Task.Delay(1000);
yield return date.AddDays(i);
}
}
}
Performance improvements
.NET 7 sees the release of the fastest System.Text.Json yet. We have invested in a number of performance-oriented improvements concerning both the internal implementation and user-facing APIs. For a detailed write-up of System.Text.Json performance improvements in .NET 7, please refer to the relevant section in Stephen Toub’s Performance Improvements in .NET 7 article.
Breaking changes
As part of our efforts to make System.Text.Json more reliable and consistent, the .NET 7 release includes a number of necessary breaking changes. These typically concern rectifying inconsistencies of components shipped in earlier releases of the library. We have documented each of the breaking changes, including potential workarounds should these impact migration of your apps to .NET 7:
- JsonSerializerOptions copy constructor includes JsonSerializerContext
- Polymorphic serialization for object types
- System.Text.Json source generator fallback
Closing
Our focus for System.Text.Json this year has been to make the library more extensible and improve its consistency and reliability, while also committing to continually improving performance year-on-year. We’d like you to try the new features and give us feedback on how it improves your applications, and any usability issues or bugs that you might encounter.
Community contributions are always welcome. If you’d like to contribute to System.Text.Json, check out our list of help wanted
issues on GitHub.
The post What’s new in System.Text.Json in .NET 7 appeared first on .NET Blog.
Comments
Post a Comment