You could adopt the Optional<T>
pattern from the question Custom JSON serializer for optional property with System.Text.Json by Maxime Rossini to wrap your values in an Optional<T>
struct that tracks whether or not the value was ever initialized. Since you are using Json.NET you will need to port its logic from System.Text.Json.
First, define the following interface, struct and converter:
public interface IHasValue
{
bool HasValue { get; }
object? GetValue();
}
[JsonConverter(typeof(OptionalConverter))]
public readonly struct Optional<T> : IHasValue
{
//Taken from https://stackoverflow.com/q/63418549/3744182
//By https://stackoverflow.com/users/547733/maxime-rossini
public Optional(T value) => (this.HasValue, this.Value) = (true, value);
public bool HasValue { get; }
public T Value { get; }
object? IHasValue.GetValue() => Value;
public override string ToString() => this.HasValue ? (this.Value?.ToString() ?? "null") : "unspecified";
public static implicit operator Optional<T>(T value) => new Optional<T>(value);
public static implicit operator T(Optional<T> value) => value.Value;
}
class OptionalConverter : JsonConverter
{
static Type? GetValueType(Type objectType) =>
objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Optional<>) ? objectType.GetGenericArguments()[0] : null;
public override bool CanConvert(Type objectType) => GetValueType(objectType) != null;
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var valueType = GetValueType(objectType) ?? throw new ArgumentException(objectType.ToString());
var value = serializer.Deserialize(reader, valueType);
return Activator.CreateInstance(objectType, value);
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
=> serializer.Serialize(writer, ((IHasValue?)value)?.GetValue());
}
Now modify your classes (here Tmp2
) and replace the value of every property whose presence you want to track with an Optional<T>
wrapper like so:
public class Tmp2
{
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public Optional<int> A { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public Optional<int?> B { get; set; }
}
And now you will be able to tell whether any particular value was ever set by checking value.HasValue
, e.g.:
Assert.That(!JsonConvert.DeserializeObject<Tmp2>("{}")!.A.HasValue);
Assert.That(JsonConvert.SerializeObject(new Tmp2 { B = 32 }) == "{\"B\":32}");
In order to suppress output of unset properties when serialized, you have two options:
Mark each property with `[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] :
Ignore members where the member value is the same as the member's default value when serializing objects so that it is not written to JSON.
Introduce a custom contract resolver that automatically suppresses serialization of unset property values.
The version of Tmp2
above uses approach #1 but if you prefer #2, define the following contract resolver:
public class OptionalResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
if (property.ValueProvider != null && property.Readable && (typeof(IHasValue).IsAssignableFrom(property.PropertyType) || typeof(IHasValue).IsAssignableTo(property.PropertyType)))
{
var old = property.ShouldSerialize;
Predicate<object> shouldSerialize = (o) => property.ValueProvider.GetValue(o) is IHasValue v ? v.HasValue : true;
property.ShouldSerialize = (old == null ? shouldSerialize : (o) => old(o) && shouldSerialize(o));
}
return property;
}
}
And simplify your model as follows:
public record Tmp2(Optional<int> A, Optional<int?> B);
And you will now be able to round-trip Tmp2
and track the presence of properties using the following settings:
// Cache this statically somewhere for best performance
IContractResolver resolver = new OptionalResolver
{
// Configure as required, e.g.:
NamingStrategy = new CamelCaseNamingStrategy(),
};
var settings = new JsonSerializerSettings { ContractResolver = resolver };
var tmp = JsonConvert.DeserializeObject<Tmp2>(json, settings);
var newJson = JsonConvert.SerializeObject(tmp, settings);
Assert.That(JToken.DeepEquals(JToken.Parse(json), JToken.Parse(newJson)));
Demo fiddle here.