Disabling the required modifier informing System.Text.Json
Today, I looked into
C# 11's
new features, which include the required
modifier. According to
the docs,
this is what it does:
The
required
modifier indicates that the field or property it's applied to must be initialized by an object initializer. Any expression that initializes a new instance of the type must initialize all required members. Therequired
modifier is available beginning with C# 11. Therequired
modifier enables developers to create types where properties or fields must be properly initialized, yet still allow initialization using object initializers.
This is great because I can now define a type like the following:
public record MyRecord
{
public required string MyValue { get; set; }
}
And when I go to initialize it I will get a compile time error when not specifying this property in an object initializer, similar to how TypeScript works by default:
var myValue = new MyRecord
{
// Error - CS9035 - Required member 'MyRecord.MyValue' must be
// set in the object initializer or attribute constructor.
};
required
modifier and System.Text.Json
Here lies the problem. After upgrading some code to use required
, I started
getting runtime exceptions. The reason is that in .NET 7, three ways were added
to mark a property or field as required for JSON deserialization:
There are three ways to mark a property or field as required for JSON deserialization:
- By adding the required modifier, which is new in C# 11.
- By annotating it with JsonRequiredAttribute, which is new in .NET 7.
- By modifying the JsonPropertyInfo.IsRequired property of the contract model, which is new in .NET 7.
In my opinion, and without knowing all the details, the required
modifier
should not have been on that list. It all boils down to this:
Ensuring that a property appears in an object initializer and ensuring that a property is required for JSON serialization are separate matters.
Exception 1 - Nullable types without a JSON property
Take this example:
using System.Text.Json;
var result = JsonSerializer.Deserialize<MyRecord>("{}");
Console.WriteLine(result?.MyValue);
public record MyRecord
{
public string? MyValue { get; set; }
}
Here I have a nullable string property. In this example, it will deserialize to
null
because it doesn't appear as a property in the empty JSON object. This
code works fine.
Now let's upgrade to C# 11 and take advantage of the required
keyword to
ensure that this property is always assigned to in an object initializer:
public record MyRecord
{
public required string? MyValue { get; set; }
}
We've just created a runtime exception in the above code.
System.Text.Json.JsonException: 'JSON deserialization for type 'MyRecord' was missing required properties, including the following: MyValue'
In my opinion, the required
modifier should have no effect on this and
deserialization should not throw an exception similar to before. This would be a
far less error-prone default for users of the API. How often do developers care
about nullable properties not appearing in the JSON? Nullable properties are
often excluded to reduce the serialized data's size.
Instead, if I wanted this behaviour, I should instead have been able to opt into
it via the JsonRequiredAttribute
:
// my desired API for the above behaviour
public record MyRecord
{
[JsonRequired]
public required string? MyValue { get; set; }
}
.NET Runtime issue that was closed as by design: #76527
Exception 2 - Ignoring a property with a required
modifier in deserialization
Say we have some data that we use in our application on the server and we also want to send it to the client, but without a property. This type is only ever serialized and never deserialized.
Instead of defining a new type, we could be a bit lazy and just mark the
property as ignored via [JsonIgnore]
.
public record MyRecord
{
// should be sent to the client
public string MyClientProperty { get; set; } = null!;
// should only be accessible on the server and not sent to the client
[JsonIgnore]
public string MyServerProperty { get; set; } = null!;
}
This works fine. Let's upgrade to using the required
modifier in C# 11 to
ensure the server always assigns to this property in object initializers:
public record MyRecord
{
// should be sent to the client
public required string MyClientProperty { get; set; }
// should only be accessible on the server
[JsonIgnore]
public required string MyServerProperty { get; set; }
}
We've unfortunately just introduced a runtime exception in our code:
System.InvalidOperationException: 'JsonPropertyInfo 'MyServerProperty' defined in type 'MyRecord' is marked required but does not specify a setter.'
This fails because System.Text.Json
does serialization AND deserialization
validation on the type. The required
modifier means this will fail
deserialization (which we won't ever do in this case). If anything, it seems
there should be a way to mark a type as serializable only, similar to what
serde does.
.NET Runtime issues that were closed as by design:
Solution
The above two runtime exceptions were just what I ran into within a few minutes
of trying out the required
modifier so there might be more.
To figure out how to get my desired behaviour, within System.Text.Json
we can
see it does the following to determine if a property is required or not
(Source):
propertyInfo.IsRequired =
memberInfo.GetCustomAttribute<JsonRequiredAttribute>(inherit: false) != null
// shouldCheckForRequiredKeyword is based on the context of where the property appears
|| (shouldCheckForRequiredKeyword && memberInfo.HasRequiredMemberAttribute());
Luckily, they have provided a way to override this and a hint is given on the
Required properties page
I linked to earlier. Essentially, we need to create a custom TypeInfoResolver
that builds upon the functionality of the DefaultJsonTypeInfoResolver
to only
set a property as required when it has the JsonRequiredAttribute
.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
var result = JsonSerializer.Deserialize<MyRecord>(
"{}",
new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
static typeInfo =>
{
foreach (var info in typeInfo.Properties)
{
if (info.IsRequired)
{
info.IsRequired = info.AttributeProvider?.IsDefined(
typeof(JsonRequiredAttribute),
inherit: false
) ?? false;
}
}
}
}
},
}
);
Console.WriteLine(result?.MyValue);
public record MyRecord
{
// [JsonRequired] // uncomment the attribute to see this take effect
public required string? MyValue { get; set; }
}
In ASP.NET Core, you can set this globally when configuring your JSON options:
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.TypeInfoResolver =
new DefaultJsonTypeInfoResolver
{
// same code as above goes here
};
});
Now, you can upgrade to liberally using the required
modifier without worrying
about introducing probably needless System.Text.Json
runtime exceptions.
Hope it helps!