DEV Community

Vegard Løkken
Vegard Løkken

Posted on

PATCH-ing a resource with nullable properties

In RESTful APIs it's common to provide a PATCH endpoint for clients to partially update a resource. If the resource doesn't have any nullable properties, that can be solved by using null as a default value to indicate that a property was omitted.

public class PatchTodoDto
{
    public string? Title { get; init; }
    public bool? Done { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core will then use null by default if the property was not provided. And in your application logic, you would have something like:

Todo updatedTodo = originalTodo with
{
    dto.Title ?? originalTodo.Title,
    dto.Done ?? originalTodo.Done
};
Enter fullscreen mode Exit fullscreen mode

For resources WITH nullable properties though, you need a way to separate between null and "undefined" (to make a javascript reference).

Introducing the Option type

By the means of some JSON converter magic and implicit casting, we can have a type Option represent all the three possible states of a patched property: value, null and undefined.

public readonly record struct Option<T>(T Value)
{
    public bool HasValue { get; } = true;
    public static implicit operator Option<T>(T value) => new(value);
}
Enter fullscreen mode Exit fullscreen mode

With this type, we can now modify our DTO type to handle nullable properties.

public class PatchTodoDto
{
    public Option<string> Title { get; init; }
    public Option<bool> Done { get; init; }
    public Option<DateTimeOffset?> DueDate { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

And the application logic now becomes:

Todo updatedTodo = originalTodo with
{
    dto.Title.HasValue ? dto.Title.Value : originalTodo.Title,
    dto.Done.HasValue ? dto.Done.Value : originalTodo.Done,
    dto.DueDate.HasValue ? dto.DueDate.Value : originalTodo.DueDate
};
Enter fullscreen mode Exit fullscreen mode

With a tiny extension method, it becomes even nicer:

Todo updatedTodo = originalTodo with
{
    dto.Title.GetValueOrDefault(originalTodo.Title),
    dto.Done.GetValueOrDefault(originalTodo.Done),
    dto.DueDate.GetValueOrDefault(originalTodo.DueDate)
};

public static class OptionExtensions
{
    [return: NotNullIfNotNull(nameof(@default))]
    public static T? GetValueOrDefault<T>(this Option<T> option, T? @default = default)
        => option.HasValue ? option.Value : @default;
}
Enter fullscreen mode Exit fullscreen mode

This way null means null and you have a more explicit contract.

Infrastructure

To be able to use the Option type in ASP.NET Core, you must configure a custom JSON converter when setting up the services:

services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.Converters.Add(new OptionJsonConverterFactory());
});
Enter fullscreen mode Exit fullscreen mode

And the implementation of the converter is as follows:

public class OptionJsonConverterFactory : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
        => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Option<>);

    public override JsonConverter CreateConverter(
            Type type,
            JsonSerializerOptions options)
    {
        Type typeArgument = type.GetGenericArguments()[0];

        var converter = (JsonConverter)Activator.CreateInstance(
            typeof(OptionJsonConverterInner<>).MakeGenericType(typeArgument),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: [options],
            culture: null)!;

        return converter;
    }

    private class OptionJsonConverterInner<T>(JsonSerializerOptions options) : JsonConverter<Option<T>>
    {
        private readonly JsonConverter<T> _converter = (JsonConverter<T>)options.GetConverter(typeof(T));

        public override Option<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            => new(_converter.Read(ref reader, typeof(T), options)!);

        public override void Write(Utf8JsonWriter writer, Option<T> value, JsonSerializerOptions options)
            => throw new NotImplementedException();
    }
}
Enter fullscreen mode Exit fullscreen mode

I hope this can be of help to others when implementing PATCH methods in their RESTful APIs.

Happy hacking!

Top comments (0)