Repository navigation
Guidance for handling Null vs Undefined in .NET JSON #123207
Replies: 6 comments 1 reply
|
I'm a bot. Here are possible related and/or duplicate issues (I may be wrong): |
|
Tagging subscribers to this area: @dotnet/area-system-text-json |
|
I didn't notice I was posting under the Fern support account. I'm the author of this issue. |
In my opinion, the best way to encode the presence or absence of a property within JSON is to use one of the JSON DOM types. Using a POCO is bound to lose some fidelity since there is no native concept of a property being absent. If I absolutely needed to I would probably go for a wrapper type like the one you described, although I think the using System.Text.Json;
using System.Text.Json.Serialization;
string json = JsonSerializer.Serialize(new MyPoco() { Null = null, Defined = "defined" });
Console.WriteLine(json);
var result = JsonSerializer.Deserialize<MyPoco>(json);
Console.WriteLine(result.Undefined.HasValue);
Console.WriteLine(result.Null.HasValue);
public class MyPoco
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Option<string?> Undefined { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Option<string?> Null { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Option<string?> Defined { get; set; }
}
[JsonConverter(typeof(OptionConverterFactory))]
public readonly struct Option<T>
{
public bool HasValue { get; }
public T Value { get; }
public Option(T value)
{
HasValue = true;
Value = value;
}
public static implicit operator Option<T>(T value) => new(value);
}
public sealed class OptionConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert) =>
typeToConvert.IsGenericType &&
typeToConvert.GetGenericTypeDefinition() == typeof(Option<>);
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
Type valueType = typeToConvert.GetGenericArguments()[0];
Type converterType = typeof(OptionConverter<>).MakeGenericType(valueType);
return (JsonConverter?)Activator.CreateInstance(converterType);
}
}
public sealed class OptionConverter<T> : JsonConverter<Option<T>>
{
public override bool HandleNull => true;
public override Option<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new(JsonSerializer.Deserialize<T>(ref reader, options)!);
}
public override void Write(Utf8JsonWriter writer, Option<T> value, JsonSerializerOptions options)
{
if (!value.HasValue)
{
// If absolutely necessary, write undefined values as null although
// this should be gated using JsonIgnoreCondition.WhenWritingDefault
writer.WriteNullValue();
return;
}
JsonSerializer.Serialize(writer, value.Value, options);
}
} |
|
We use a pattern similar to the OData deltas: https://github.com/OData/AspNetCoreOData/blob/main/src/Microsoft.AspNetCore.OData/Deltas/DeltaOfT.cs The specialized JSON serializer for the deltas store the the information in a key-value property bag. Then you can either obtain the values/nested values via the getters (by name, by expression, looping through them) or you apply the modifications directly to a given object. It's a bit tricky to get everything aligned (JSON serialization, OpenAPI docs with correct optional/required field declaratiosn etc.) but once you got the main things in place, the approach is really nice for strict RESTful services. You can then simply accept a |
|
I agree with the approach of keeping property presence separate from nullability. One thing I'd pay particular attention to is the default state of the Optional struct. For a PATCH request, an omitted property should remain undefined, while an explicitly provided null should be treated as a defined value whose content is null. Using a converter with a HasValue flag makes this distinction quite clear. Combined with JsonIgnoreCondition.WhenWritingDefault, the default Optional value can be omitted during serialization, while an explicitly provided null can still be serialized as JSON null. I also think using a JSON DOM is a good option when you need more flexibility or want to preserve the original JSON structure. However, for strongly typed API request DTOs, a small wrapper type can provide a cleaner programming experience. Regarding Optional<T?>, I would keep the two concepts separate:
That makes the API contract easier to understand, especially when implementing PATCH operations. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The Problem
We've been building HTTP SDKs and OpenAPI clients in C#, and we've been running into this issue with representing three distinct states for a field:
The problem is that C#'s nullable types (
string?,int?) can only represent two states: null or value. There's no way to distinguish between "not provided" and "explicitly set to null."Example
Let's say you have a PATCH endpoint for updating a user profile:
With standard C# types, you can't express this:
string? Email = null- Does this mean "clear it" or "not provided"?What I've Built So Far
I ended up implementing an
Optional<T>type that wraps nullable types to give us that third state:How It Works
Here's what using it looks like:
Serialization Behavior
Quick reference for how different combinations serialize:
string? Fieldnull[Nullable] string? Fieldnullnull[Nullable] string? FieldOf("text")"text"[Optional] string? Fieldnull[Optional] string? FieldOf("text")"text"[Optional] Optional<string> FieldUndefined[Optional] Optional<string> FieldOf("text")"text"[Optional, Nullable] Optional<string?> FieldUndefined[Optional, Nullable] Optional<string?> FieldOf(null)null[Optional, Nullable] Optional<string?> FieldOf("text")"text"What's in the Repo
The implementation includes:
Optional<T>struct with IEquatable supportJsonConverterandJsonTypeInfoResolverusing reflection to check[Optional]and[Nullable]attributesOptional<T>values in assertionsKey files in this repository:
Optional.cs- Core implementation with IOptional interfaceOpenApiSerialization.cs- JSON serialization with attribute-based modifiersOptionalAttribute.cs&NullableAttribute.cs- Attribute markersOpenApiSerializationTests.cs- Comprehensive test coverageOptionalComparerExtensions.cs- NUnit test helpersQuestions
I'm looking for guidance on whether this is the right approach:
1. Is there an existing .NET pattern I should be using instead?
2. JSON serialization - am I doing this right?
JsonTypeInfoResolverwith reflection on custom attributes reasonable?3. API design - the
Optional<T?>questionI chose to separate concerns:
Optional<T>= "Is the field present in the request?"T?= "Can the field value be null?"This leads to
Optional<T?>for optional nullable fields. Is this the right approach, or should I build null handling intoOptional<T>itself (like having anOptional<T>.Nullstate)?Full working implementation with tests is in this repo. All tests pass on .NET 8.0, .NET 10.0, and .NET Framework 4.6.2.
Would really appreciate any guidance on the recommended .NET approach here!
All reactions