Skip to content

Repository files navigation

OptionalValues

A .NET library that provides an OptionalValue<T> type, representing a value that may or may not be specified, with comprehensive support for JSON serialization. e.g. (undefined, null, "value")

NuGetLicenseGitHub Actions Workflow Status

PackageVersion
OptionalValuesNuGet
OptionalValues.OpenApiNuGet
OptionalValues.MvcNuGet
OptionalValues.SwashbuckleNuGet
OptionalValues.NSwagNuGet
OptionalValues.DataAnnotationsNuGet
OptionalValues.FluentValidationNuGet

Overview

The OptionalValue<T> struct is designed to represent a value that can be in one of three states:

  • Unspecified: The value has not been specified. (e.g. undefined)
  • Specified with a non-null value: The value has been specified and is not null.
  • Specified with a null value: The value has been specified and is null.

Why

When working with Json it's currently difficult to know whether a property was omitted or explicitly null. This makes it hard to support older clients that don't send all properties in a request. By using OptionalValue<T> you can distinguish between null and Unspecified values.

usingSystem.Text.Json;usingOptionalValues;varjsonSerializerOptions=newJsonSerializerOptions().AddOptionalValueSupport();varjson=""" { "FirstName": "John", "LastName": null } """;varperson1=JsonSerializer.Deserialize<Person>(json,jsonSerializerOptions);// equals:varperson2=newPerson{FirstName="John",LastName=null,Address=OptionalValue<string>.Unspecified// or default};boolareEqual=person1==person2;// Truestringserialized=JsonSerializer.Serialize(person2,jsonSerializerOptions);// Output: {"FirstName":"John","LastName":null}publicrecordPerson{publicOptionalValue<string>FirstName{get;set;}publicOptionalValue<string?>LastName{get;set;}publicOptionalValue<string>Address{get;set;}}

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues

For JSON serialization support, configure the JsonSerializerOptions to include the OptionalValue<T> converter:

varoptions=newJsonSerializerOptions().AddOptionalValueSupport();

Optionally, install one or more extension packages:

dotnet add package OptionalValues.Swashbuckle
dotnet add package OptionalValues.NSwag
dotnet add package OptionalValues.Mvc
dotnet add package OptionalValues.DataAnnotations
dotnet add package OptionalValues.FluentValidation

Features

  • Distinguish Between Unspecified and Null Values: Clearly differentiate when a value is intentionally null versus when it has not been specified at all. This allows for mapping undefined values in JSON to Unspecified values in C#.
  • JSON Serialization Support: Includes a custom JSON converter and TypeResolverModifier that correctly handles serialization and deserialization, ensuring unspecified values are omitted from JSON outputs.
  • Dictionary Extensions: Extension methods for working with dictionaries and OptionalValue<T>, including GetOptionalValue, AddOptionalValue, TryAddOptionalValue, and SetOptionalValue.
  • Optional DataAnnotations: An extension library that provides support for DataAnnotations validation attributes on OptionalValue<T> properties.
  • FluentValidation Extensions: Provides extension methods to simplify the validation of OptionalValue<T> properties using FluentValidation.
  • OpenApi/Swagger Support:
    • ASP.NET Core OpenAPI: Support for ASP.NET Core's built-in OpenAPI support (Microsoft.AspNetCore.OpenApi) is available through the OptionalValues.OpenApi package. It provides a schema transformer to correctly handle OptionalValue<T> types.
    • Swashbuckle Includes a custom data contract resolver for Swashbuckle to generate accurate OpenAPI/Swagger documentation.
    • NSwag: Support for NSwag is available through the OptionalValues.NSwag package. It includes an OptionalValueTypeMapper to map the OptionalValue<T> to its underlying type T in the generated OpenAPI schema.
  • Patch Operation Support: Ideal for API patch operations where fields can be updated to null or remain unchanged.

Table of Contents

Usage

Creating an OptionalValue

You can create an OptionalValue<T> in several ways:

  • Unspecified Value:

    varunspecified=newOptionalValue<string>();// orvarunspecified=OptionalValue<string>.Unspecified;// orOptionalValue<string>unspecified=default;
  • Specified Value:

    varspecifiedValue=newOptionalValue<string>("Hello, World!");// or using implicit conversionOptionalValue<string>specifiedValue="Hello, World!";
  • Specified Null Value:

    varspecifiedNull=newOptionalValue<string?>(null);// or using implicit conversionOptionalValue<string?>specifiedNull=null;

Checking If a Value Is Specified

Use the IsSpecified property to determine if the value has been specified:

if(optionalValue.IsSpecified){Console.WriteLine("Value is specified.");}else{Console.WriteLine("Value is unspecified.");}

Accessing the Value

  • .Value: Gets the value if specified; returns null if unspecified.
  • .SpecifiedValue: Gets the specified value; throws InvalidOperationException if the value is unspecified.
  • .GetSpecifiedValueOrDefault(): Gets the specified value or the default value of T if unspecified.
  • .GetSpecifiedValueOrDefault(T defaultValue): Gets the specified value or the provided default value if unspecified.
varoptionalValue=newOptionalValue<string>("Example");// Using Valuestring?value=optionalValue.Value;// Using SpecifiedValuestringspecifiedValue=optionalValue.SpecifiedValue;// Using GetSpecifiedValueOrDefaultstringvalueOrDefault=optionalValue.GetSpecifiedValueOrDefault("Default Value");

Implicit Conversions

OptionalValue<T> supports implicit conversions to and from T:

// From T to OptionalValue<T>OptionalValue<int>optionalInt=42;// From OptionalValue<T> to T (returns null if unspecified)int?value=optionalInt;

Equality Comparisons

Equality checks consider both the IsSpecified property and the Value:

varvalue1=newOptionalValue<string>("Test");varvalue2=newOptionalValue<string>("Test");varunspecified=newOptionalValue<string>();boolareEqual=value1==value2;// TrueboolareUnspecifiedEqual=unspecified==newOptionalValue<string>();// True

Dictionary Extensions

Extension methods for working with dictionaries and OptionalValue<T>:

usingOptionalValues.Extensions;varsettings=newDictionary<string,int>{["timeout"]=30};// Get value as OptionalValue (returns Unspecified if key not found)OptionalValue<int>timeout=settings.GetOptionalValue("timeout");// IsSpecified == trueOptionalValue<int>retries=settings.GetOptionalValue("retries");// IsSpecified == false// Add/Set only when value is specifiedsettings.AddOptionalValue("maxRetries",newOptionalValue<int>(3));// Adds the valuesettings.AddOptionalValue("other",OptionalValue<int>.Unspecified);// Does nothingsettings.SetOptionalValue("timeout",newOptionalValue<int>(60));// Updates to 60

JSON Serialization with System.Text.Json

OptionalValue<T> includes a custom JSON converter and JsonTypeInfoResolver Modifier to handle serialization and deserialization of optional values. To properly serialize OptionalValue<T> properties, add it to the JsonSerializerOptions:

varnewOptionsWithSupport=JsonSerializerOptions.Default.WithOptionalValueSupport();// orvaroptions=newJsonSerializerOptions();options.AddOptionalValueSupport();

Serialization Behavior

  • Unspecified Values: Omitted from the JSON output.
  • Specified Null Values: Serialized with a null value.
  • Specified Non-Null Values: Serialized with the actual value.
publicclassPerson{publicOptionalValue<string>FirstName{get;set;}publicOptionalValue<string>LastName{get;set;}}// Creating a Person instancevarperson=newPerson{FirstName="John",// Specified non-null valueLastName=newOptionalValue<string>()// Unspecified};// Serializing to JSONstringjson=JsonSerializer.Serialize(person);// Output: {"FirstName":"John"}

Deserialization Behavior

  • Missing Properties: Deserialized as unspecified values.
  • Properties with null: Deserialized as specified with a null value.
  • Properties with Values: Deserialized as specified with the given value.
stringjsonInput=@"{""FirstName"":""John"",""LastName"":null}";varperson=JsonSerializer.Deserialize<Person>(jsonInput);boolisFirstNameSpecified=person.FirstName.IsSpecified;// TruestringfirstName=person.FirstName.SpecifiedValue;// "John"boolisLastNameSpecified=person.LastName.IsSpecified;// TruestringlastName=person.LastName.SpecifiedValue;// null

Respect nullable annotations

OptionalValue<T> has support for respecting nullable annotations when enabling RespectNullableAnnotations = true in the JsonSerializerOptions. When enabled, when deserializing a null value on an OptionalValue which is NOT nullable, it will throw a JsonException with a message indicating that the value is not nullable.

JsonSerializerOptionsOptions=newJsonSerializerOptions{RespectNullableAnnotations=true,}.AddOptionalValueSupport();varjson=""" { "NotNullable": null } """;varmodel=JsonSerializer.Deserialize<Model>(json,Options);// Throws JsonExceptionprivateclassModel{publicOptionalValue<string>NotNullable{get;init;}}

There are a few limitations to this feature:

  • It only works when NOT using generics.
// it does not work with this, because the type is generic and we cannot determine if it is nullable or not as this information is not available at runtime.publicclassModel<T>{publicOptionalValue<T>NotNullable{get;init;}}

Library support

ASP.NET Core

The OptionalValues library integrates seamlessly with ASP.NET Core, allowing you to use OptionalValue<T> properties in your API models.

Configure the JsonSerializerOptions to include the OptionalValue<T> converter, and for MVC controller validation add OptionalValues.Mvc:

// For Minimal APIbuilder.Services.ConfigureHttpJsonOptions(jsonOptions =>{// Make sure that AddOptionalValueSupport() is the last call when you are using the `TypeInfoResolverChain` of the `SerializerOptions`.jsonOptions.SerializerOptions.AddOptionalValueSupport();});// For MVCbuilder.Services.AddControllers().AddJsonOptions(options =>{options.JsonSerializerOptions.AddOptionalValueSupport();}).AddMvcOptions(options =>{options.AddOptionalValueSupport();});

Or configure the MVC options directly:

builder.Services.AddControllers(options =>{options.AddOptionalValueSupport();}).AddJsonOptions(options =>{options.JsonSerializerOptions.AddOptionalValueSupport();});

ASP.NET Core OpenAPI

The OptionalValues.OpenApi package provides support for ASP.NET Core's built-in OpenAPI support (Microsoft.AspNetCore.OpenApi) to generate accurate OpenAPI documentation for OptionalValue<T> properties.

It correctly unwraps the OptionalValue<T> type and generates the appropriate schema for the underlying type T.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.OpenApi

Configure the OpenAPI services to use the OptionalValue<T> schema transformer:

builder.Services.AddOpenApi(options =>{options.AddOptionalValueSupport();});

Swashbuckle

The OptionalValues.Swashbuckle package provides a custom data contract resolver for Swashbuckle to generate accurate OpenAPI/Swagger documentation for OptionalValue<T> properties.

It correctly unwraps the OptionalValue<T> type and generates the appropriate schema for the underlying type T.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.Swashbuckle

Configure the Swashbuckle services to use the OptionalValueDataContractResolver:

builder.Services.AddSwaggerGen();// after AddSwaggerGen when you want it to use an existing custom ISerializerDataContractResolver.builder.Services.AddSwaggerGenOptionalValueSupport();

NSwag

The OptionalValues.NSwag package provides an OptionalValueTypeMapper to map the OptionalValue<T> to its underlying type T in the generated OpenAPI schema.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.NSwag

Configure the NSwag SchemaSettings to use the OptionalValueTypeMapper:

builder.Services.AddOpenApiDocument(options =>{// Add OptionalValue support to NSwagoptions.SchemaSettings.AddOptionalValueSupport();});

System.ComponentModel.DataAnnotations

The OptionalValues.DataAnnotations package provides DataAnnotations validation attributes for OptionalValue<T> properties. They are all overrides of the standard DataAnnotations attributes and prefixed with Optional. The key difference is that the validation rules are only applied when the value is specified (which is close to the default behavior which only applies it when it's not null).

Install the package using the .NET CLI:

dotnet add package OptionalValues.DataAnnotations

Presence Validators:

  • [Specified]: Ensures the OptionalValue<T> is specified (present), but allows null or empty values.
  • [RequiredValue]: Ensures the OptionalValue<T> is specified and its value is not null or empty. This should be used instead of the standard [Required] attribute.

Example usage:

publicclassExampleModel{[OptionalAllowedValues("a")]publicOptionalValue<string>AllowedValues{get;set;}[OptionalDeniedValues("a")]publicOptionalValue<string>DeniedValues{get;set;}[OptionalLength(1,5)]publicOptionalValue<int[]>LengthCollection{get;set;}[OptionalLength(1,5)]publicOptionalValue<string>LengthString{get;set;}[OptionalMaxLength(5)]publicOptionalValue<int[]>MaxLengthCollection{get;set;}[OptionalMaxLength(5)]publicOptionalValue<string>MaxLengthString{get;set;}[OptionalMinLength(1)]publicOptionalValue<int[]>MinLengthCollection{get;set;}[OptionalMinLength(1)]publicOptionalValue<string>MinLengthString{get;set;}[OptionalRange(5,42)]publicOptionalValue<int>Range{get;set;}[OptionalRegularExpression("^something$")]publicOptionalValue<string>RegularExpression{get;set;}[Specified]publicOptionalValue<string?>Specified{get;set;}[RequiredValue]publicOptionalValue<string>SpecifiedRequired{get;set;}[OptionalStringLength(5)]publicOptionalValue<string>StringLength{get;set;}}

FluentValidation

The OptionalValues.FluentValidation package provides extension methods to simplify the validation of OptionalValue<T> properties using FluentValidation.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.FluentValidation

Using OptionalRuleFor

The OptionalRuleFor extension method allows you to define validation rules for OptionalValue<T> properties that are only applied when the value is specified.

usingFluentValidation;usingOptionalValues.FluentValidation;publicclassUpdateUserRequest{publicOptionalValue<string?>Email{get;set;}publicOptionalValue<int>Age{get;set;}}publicclassUpdateUserRequestValidator:AbstractValidator<UpdateUserRequest>{publicUpdateUserRequestValidator(){this.OptionalRuleFor(x =>x.Email, x =>x.NotEmpty().EmailAddress());this.OptionalRuleFor(x =>x.Age, x =>x.GreaterThan(18));}}

In this example:

  • The validation rules for Email and Age are applied only if the corresponding OptionalValue<T> is specified.
  • If the value is unspecified, the validation rules are skipped.

How It Works

The OptionalRuleFor method:

  • Takes an expression specifying the OptionalValue<T> property.
  • Accepts a configuration function where you define your validation rules using the standard FluentValidation syntax.
  • Internally, it checks if the value is specified (IsSpecified) before applying the validation rules.

Example Usage

varvalidator=newUpdateUserRequestValidator();// Valid request with specified valuesvarvalidRequest=newUpdateUserRequest{Email="user@example.com",Age=25};varresult=validator.Validate(validRequest);// result.IsValid == true// Invalid request with specified valuesvarinvalidRequest=newUpdateUserRequest{Email="invalid-email",Age=17};varresultInvalid=validator.Validate(invalidRequest);// resultInvalid.IsValid == false// Errors for Email and Age// Request with unspecified valuesvarunspecifiedRequest=newUpdateUserRequest{Email=default,Age=default};varresultUnspecified=validator.Validate(unspecifiedRequest);// resultUnspecified.IsValid == true// Validation rules are skipped for unspecified values

Use Cases

API Patch Operations

When updating resources via API endpoints, it's crucial to distinguish between fields that should be updated to null and fields that should remain unchanged.

publicclassUpdateUserRequest{publicOptionalValue<string?>Email{get;set;}publicOptionalValue<string?>PhoneNumber{get;set;}}[HttpPatch("{id}")]publicIActionResultUpdateUser(intid,UpdateUserRequestrequest){if(request.Email.IsSpecified){// Update email to request.Email.SpecifiedValue}if(request.PhoneNumber.IsSpecified){// Update phone number to request.PhoneNumber.SpecifiedValue}// Unspecified fields remain unchangedreturnOk();}

Current Limitations

  • DataAnnotations: The OptionalValue<T> type does not support DataAnnotations validation attributes because they are tied to specific .NET Types (e.g. string).
    • "Workaround": Use the FluentValidation extensions to define validation rules for OptionalValue<T> properties.
  • Support for other libraries: Because OptionalValue<T> is a wrapper type it requires mapping to the underlying type for some libraries. Let me know if you have a specific library in mind that you would like to see support for.

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests on the GitHub repository.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Benchmarks

The project is benchmarked with BenchmarkDotNet to check any additional overhead that the OptionalValue<T> type might introduce. They are located in the /test/OptionalValues.Benchmarks directory.

Below are the results of the benchmarks for the OptionalValue<T> serialization performance on my machine:


BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2605)
13th Gen Intel Core i9-13900H, 1 CPU, 20 logical and 14 physical cores
.NET SDK 9.0.101
[Host] : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
DefaultJob : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
MethodMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
SerializePrimitiveModel102.10 ns1.296 ns1.212 ns1.000.020.0088112 B1.00
SerializeOptionalValueModel108.55 ns1.324 ns1.238 ns1.060.020.0134168 B1.50
SerializePrimitiveModelWithSourceGenerator75.65 ns1.554 ns1.727 ns0.740.020.0088112 B1.00
SerializeOptionalValueModelWithSourceGenerator93.47 ns1.690 ns1.581 ns0.920.020.0134168 B1.50

1ns = 1/1,000,000,000 seconds

It is comparing the serialization performance between these two models:

publicclassPrimitiveModel{publicintAge{get;set;}=42;publicstringFirstName{get;set;}="John";publicstring?LastName{get;set;}=null;}publicclassOptionalValueModel{publicOptionalValue<int>Age{get;set;}=42;publicOptionalValue<string>FirstName{get;set;}="John";publicOptionalValue<string>LastName{get;set;}=default;}

About

Know whether a property was Specified or Unspecified/omitted in your (json) objects.

Topics

Resources

Stars

55 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages