SelmanMade.OptionPatch 1.1.0

dotnet add package SelmanMade.OptionPatch --version 1.1.0
                    
NuGet\Install-Package SelmanMade.OptionPatch -Version 1.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SelmanMade.OptionPatch" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SelmanMade.OptionPatch" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="SelmanMade.OptionPatch" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SelmanMade.OptionPatch --version 1.1.0
                    
#r "nuget: SelmanMade.OptionPatch, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SelmanMade.OptionPatch@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SelmanMade.OptionPatch&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=SelmanMade.OptionPatch&version=1.1.0
                    
Install as a Cake Tool

OptionPatch

A source-generated PATCH semantics library for .NET. Define your patch DTOs with Option<T> properties, and the generator writes the boring mapping code at build time — zero reflection, zero runtime cost.

Table of Contents


Installation

Install the NuGet package:

dotnet add package SelmanMade.OptionPatch

Or via the Package Manager Console in Visual Studio:

Install-Package SelmanMade.OptionPatch

The package includes both the runtime types (Option<T>, PatchEntry, attributes) and the source generator. No additional packages are needed.


Quick Start

using OptionPatch;

// 1. Mark your patch DTO with the target entity type
[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
    public Option<string?> Name { get; set; }
    public Option<int?> Age { get; set; }
}

// 2. Apply a patch — only set properties are touched
var person = new Person { Name = "Alice", Age = 30 };
var patch = new UpdatePerson { Name = "Bob" };

UpdatePersonPatchApplier.Apply(patch, person);
// person.Name == "Bob", person.Age == 30 (unchanged)

// 3. Or generate path-based entries for Cosmos DB / JSON Patch
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/name, Bob)

Step 1: Define Your Entities

These are your normal domain/entity classes. Nothing special required.

public class Person
{
    public string? Name { get; set; }
    public int? Age { get; set; }
    public Address? Address { get; set; }
}

public class Address
{
    public string? Street { get; set; }
    public string? City { get; set; }
}

Step 2: Define Patch DTOs

Create a class for each entity you want to patch. Use Option<T> for every property and annotate the class with [GeneratePatchApplier(typeof(TargetEntity))].

using OptionPatch;

[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
    public Option<string?> Name { get; set; }
    public Option<int?> Age { get; set; }
    public Option<UpdateAddress?> Address { get; set; }
}

[GeneratePatchApplier(typeof(Address))]
public class UpdateAddress
{
    public Option<string?> Street { get; set; }
    public Option<string?> City { get; set; }
}

Key rules:

Rule Why
Property names must match the entity The generator matches by name
Entity properties must have a setter Read-only properties are skipped (with a warning)
Use Option<T> from OptionPatch The generator only processes properties with the [Option]-marked struct

Understanding Option<T>

Option<T> distinguishes between "not provided" and "explicitly set to null" — the core problem with PATCH APIs:

var patch = new UpdatePerson();
// patch.Name.IsSet == false  → "Name was not in the request, don't touch it"

var patch2 = new UpdatePerson { Name = "Alice" };
// patch2.Name.IsSet == true, patch2.Name.Value == "Alice"  → "Set Name to Alice"

var patch3 = new UpdatePerson { Name = (string?)null };
// patch3.Name.IsSet == true, patch3.Name.Value == null  → "Clear Name"

Binding from JSON

Option<T> ships with a System.Text.Json converter and is annotated with it directly, so patch DTOs bind from a request body with no registration and no configuration:

[HttpPatch("{id}")]
public async Task<IActionResult> Patch(string id, UpdatePerson patch)
{
    var person = await _repository.GetAsync(id);
    UpdatePersonPatchApplier.Apply(patch, person);
    await _repository.UpdateAsync(person);
    return NoContent();
}

The three PATCH states fall out of how JSON works — a property STJ never sees keeps its default(Option<T>) value, which is "not set":

Request body Result
{"name":"Bob"} Name.IsSet == true, Value == "Bob" → set it
{} Name.IsSet == false → leave it alone
{"name":null} Name.IsSet == true, Value == null → clear it

Serializing a patch DTO

Serialization is the awkward direction: a converter cannot omit the property it was called for, so an unset option writes as null — which would read back as "clear this". If you need to serialize patch DTOs faithfully, add the supplied modifier, which drops unset properties instead:

var options = new JsonSerializerOptions
{
    TypeInfoResolver = new DefaultJsonTypeInfoResolver
    {
        Modifiers = { OptionJson.OmitUnsetProperties },
    },
};

JsonSerializer.Serialize(new UpdateChild { Name = "Bob" }, options);
// {"Name":"Bob"}   — Age is unset, so it's omitted entirely

This only matters when you write patch DTOs. Deserialization — the common case — needs nothing.


Step 3: Apply Patches to Entities

At build time, the generator creates a static {DtoName}PatchApplier class with an Apply method:

var person = new Person { Name = "Alice", Age = 30 };

var patch = new UpdatePerson { Name = "Bob" };
// Age is not set — it won't be touched

UpdatePersonPatchApplier.Apply(patch, person);

// person.Name == "Bob"
// person.Age == 30  (unchanged)

Only properties where IsSet == true are applied. Everything else is left untouched.


Step 4: Generate Patch Entries

The generator also creates a ToPatchEntries method that produces a list of PatchEntry objects — a generic intermediate representation with JSON paths and values. This is what you use for Cosmos DB, JSON Patch, or any path-based patch format.

var patch = new UpdatePerson
{
    Name = "Bob",
    Age = (int?)null
};

IReadOnlyList<PatchEntry> entries = UpdatePersonPatchApplier.ToPatchEntries(patch);

// entries[0]: Set(/name, Bob)
// entries[1]: Set(/age, null)

Each PatchEntry has:

Property Type Description
Path string JSON pointer path (e.g. /name, /address/city)
Value object? The value to set, or null
IsRemoval bool true if this is a remove operation

Nested DTOs

When a patch DTO property is itself a patch DTO (annotated with [GeneratePatchApplier]), the generator handles nesting automatically.

Partial update of a nested object

var patch = new UpdatePerson
{
    Address = new UpdateAddress { City = "Shelbyville" }
};

// Apply: creates Address if null, updates only City
UpdatePersonPatchApplier.Apply(patch, person);

// ToPatchEntries: produces flattened path
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/address/city, Shelbyville)

Remove a nested object

var patch = new UpdatePerson
{
    Address = (UpdateAddress?)null
};

// Apply: sets person.Address = null
UpdatePersonPatchApplier.Apply(patch, person);

// ToPatchEntries: produces a Remove entry
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Remove(/address)

Leave a nested object untouched

var patch = new UpdatePerson
{
    Name = "Bob"
    // Address not set at all — completely untouched
};

Collections and Dictionaries

OptionPatch supports list-like collections and dictionaries with whole-value replacement semantics:

State Result
Option is set to a value The entire collection/dictionary on the entity is replaced
Option is unset The entity value is left untouched
Option is set to null The entity value is cleared (Apply) / emitted as a Remove entry (ToPatchEntries)

Supported types

Collections: List<T>, T[] (arrays), and the interfaces IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>.

Dictionaries: Dictionary<TKey, TValue>, IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue>.

The element type (or dictionary value type) can be a simple type (string, int, …) or another patch DTO annotated with [GeneratePatchApplier]. Interface- and array-typed entity properties are materialized into a concrete List<T> / T[] / Dictionary<TKey, TValue>.

Simple collections and dictionaries

public class Team
{
    public List<string>? Tags { get; set; }
    public Dictionary<string, string>? Labels { get; set; }
}

[GeneratePatchApplier(typeof(Team))]
public partial class UpdateTeam
{
    public Option<List<string>?> Tags { get; set; }
    public Option<Dictionary<string, string>?> Labels { get; set; }
}
var team = new Team { Tags = new() { "old" } };

var patch = new UpdateTeam
{
    Tags = new List<string> { "red", "blue" }
    // Labels not set — leave it untouched
};

UpdateTeamPatchApplier.Apply(patch, team);
// team.Tags  == ["red", "blue"]  (whole list replaced)
// team.Labels is unchanged

The entity always receives a fresh copy — the patch DTO's collection instance is never aliased into the entity.

Getter-only collection properties

Entities often expose collections without a setter — the shape analyzer rule CA2227 recommends:

public class Team
{
    public List<string> Tags { get; } = new();
    public Dictionary<string, string> Labels { get; } = new();
}

These are patched in place: the existing instance is cleared and refilled, rather than replaced. The property identity is preserved, which matters if anything else holds a reference to it.

var tags = team.Tags;
UpdateTeamPatchApplier.Apply(new UpdateTeam { Tags = ["red", "blue"] }, team);

// team.Tags == ["red", "blue"]
// ReferenceEquals(tags, team.Tags) == true  — same instance, refilled

Setting the option to null clears the collection, since there is no setter to assign null to. ToPatchEntries is unaffected and still emits a Remove entry.

This requires a mutable surface — ICollection<T> for collections, IDictionary<TKey, TValue> for dictionaries. Getter-only arrays, IReadOnlyList<T>, and IReadOnlyDictionary<TKey, TValue> can't be filled in place and are skipped with a PATCH002 warning.

Collections and dictionaries of nested DTOs

When the element (or dictionary value) type is itself a patch DTO, each entry is projected onto a fresh entity instance using its generated applier — so partial-update semantics apply per element:

public class Team
{
    public List<ChildEntity>? Members { get; set; }
}

[GeneratePatchApplier(typeof(Team))]
public partial class UpdateTeam
{
    public Option<List<UpdateChild>?> Members { get; set; }
}
var patch = new UpdateTeam
{
    Members = new List<UpdateChild>
    {
        new UpdateChild { Name = "Bob", Age = 5 },
        new UpdateChild { Name = "Sue" } // Age left unset on the new entity
    }
};

UpdateTeamPatchApplier.Apply(patch, team);
// team.Members is a List<ChildEntity> with two fresh entities

ToPatchEntries

Collections and dictionaries produce a single entry that replaces the whole value at the property path:

var entries = UpdateTeamPatchApplier.ToPatchEntries(new UpdateTeam
{
    Tags = new List<string> { "red", "blue" }
});
// entries[0]: Set(/tags, [red, blue])   — Value is a List<string>

Setting a collection or dictionary to null produces a Remove entry (consistent with nested DTOs):

var entries = UpdateTeamPatchApplier.ToPatchEntries(new UpdateTeam
{
    Tags = (List<string>?)null
});
// entries[0]: Remove(/tags)

For nested-DTO collections/dictionaries, the entry's Value is the materialized collection of entity objects (ready to serialize), not the patch DTOs.

Whole-value replacement, not element-level merging. Patching individual array indices or single dictionary keys is not generated — replace the whole collection or dictionary instead.


Integrations

PatchEntry is deliberately free of any storage or transport dependency. It's a small, generic intermediate representation (Path, Value, IsRemoval) that you translate into whatever patch format your target expects — Azure Cosmos DB, JSON Patch (RFC 6902), MongoDB updates, or your own custom format.

Ready-made adapter packages

For Azure Cosmos DB, there are two optional adapter packages so you don't have to write the mapping yourself:

Package Target Produces
SelmanMade.OptionPatch.Cosmos Azure Cosmos DB SDK (Microsoft.Azure.Cosmos) IReadOnlyList<PatchOperation>
SelmanMade.OptionPatch.CosmosRepository IEvangelist.Azure.CosmosRepository Action<IPatchOperationBuilder<T>>

Each exposes an extension method on IReadOnlyList<PatchEntry>:

using OptionPatch.Cosmos;

var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);

// SelmanMade.OptionPatch.Cosmos
var ops = entries.ToPatchOperations();
await container.PatchItemAsync<Person>(id, partitionKey, ops);
using OptionPatch.CosmosRepository;

var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);

// SelmanMade.OptionPatch.CosmosRepository
await repository.UpdateAsync(id, entries.ToPatchBuilder<Person>(), partitionKey);

See each package's README for details and limitations (the CosmosRepository adapter, for example, matches top-level properties only).

Writing your own adapter

Targeting something else — MongoDB, a message queue, a custom REST payload? Write a thin adapter against PatchEntry. The whole pattern is one loop that switches on IsRemoval. Here's the Cosmos SDK adapter as an example (this is exactly what SelmanMade.OptionPatch.Cosmos ships):

using Microsoft.Azure.Cosmos;
using OptionPatch;

public static class CosmosPatchAdapter
{
    public static IReadOnlyList<PatchOperation> ToPatchOperations(
        this IReadOnlyList<PatchEntry> entries)
    {
        var ops = new List<PatchOperation>(entries.Count);

        foreach (var entry in entries)
        {
            ops.Add(entry.IsRemoval
                ? PatchOperation.Remove(entry.Path)
                : PatchOperation.Set(entry.Path, entry.Value));
        }

        return ops;
    }
}

Usage:

var patch = new UpdatePerson
{
    Name = "Alice",
    Address = (UpdateAddress?)null
};

var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
var ops = entries.ToPatchOperations();

await container.PatchItemAsync<Person>(id, partitionKey, ops);
// Sends: Set(/name, "Alice"), Remove(/address)

To target a different backend, swap the body of the loop: map entry.Path / entry.Value to your format's "set" operation, and entry.IsRemoval to its "remove" operation. Write one adapter per target.


JSON Property Name Support

The generator resolves JSON property names for ToPatchEntries paths using this order:

  1. [JsonPropertyName("...")] (System.Text.Json) on the entity property
  2. [JsonProperty("...")] or [JsonProperty(PropertyName = "...")] (Newtonsoft.Json) on the entity property
  3. camelCase fallback — converts the C# property name (e.g. ZipCodezipCode)

Example with explicit JSON names:

using System.Text.Json.Serialization;

public class Person
{
    [JsonPropertyName("full_name")]
    public string? Name { get; set; }

    public int? Age { get; set; }
}

Generated paths will be /full_name and /age.

Newtonsoft matters for Cosmos. The Cosmos DB SDK v3 serializes with Newtonsoft.Json by default, so entities in a Cosmos codebase are commonly annotated [JsonProperty("...")]. Those names are honoured — without that, paths would silently fall back to camelCase and fail to match the stored document.


Diagnostics

The generator emits build-time diagnostics when something doesn't map correctly:

ID Severity Description
PATCH001 Error Property types are incompatible (e.g. Option<string>int target)
PATCH002 Warning DTO property has no matching writable property on the target entity
PATCH003 Error Target property is init-only (including record positional parameters), so it cannot be assigned after construction

These appear as regular build errors/warnings in Visual Studio, so you catch mapping mistakes at compile time instead of runtime.


How It Works

At build time, the incremental source generator:

  1. Scans for classes annotated with [GeneratePatchApplier(typeof(T))]
  2. Matches each Option<T> property by name to the target entity's properties
  3. Emits a {DtoName}PatchApplier.g.cs file with two methods:
    • Apply(patch, target) — direct property assignment on the entity
    • ToPatchEntries(patch, prefix) — JSON path + value list

You can inspect the generated files in your project's obj folder or via Visual Studio's Analyzers node in Solution Explorer.

The generated code is plain C# with no reflection, no expression trees, and no runtime compilation.


Limitations

By design:

  • Whole-value replacement for collections and dictionaries. Element-level merging and per-key dictionary entries are not generated — see Collections and Dictionaries. This matches JSON Merge Patch (RFC 7386) semantics. Note that replacing a whole dictionary overwrites concurrent writes to keys you didn't touch.

Not supported — the generator tells you at build time:

  • Immutable/init-only targets, including positional records. Reported as PATCH003. Apply assigns properties after construction, so an init accessor can't be used; there is no with-expression or constructor-based creation.
  • Targets with no usable setter, other than mutable collections and dictionaries (which are filled in place). Getter-only arrays, IReadOnlyList<T>, IReadOnlyDictionary<TKey, TValue>, and private/protected setters are reported as PATCH002 and skipped.

Not supported — silent:

  • Custom property name mappings on the DTO side. DTO and entity properties are matched by exact name; there is no [PatchProperty("OtherName")] equivalent. (JSON path names are separate and are resolved from the entity — see JSON Property Name Support.)
  • Inherited entity properties. Only properties declared directly on the target type are matched; a property on a base class is reported as PATCH002.
  • Cycle detection in nested DTOs. A self-referential patch DTO with cyclic data will recurse until the stack overflows. Rare in practice, but not guarded against.
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on SelmanMade.OptionPatch:

Package Downloads
SelmanMade.OptionPatch.CosmosRepository

IEvangelist.Azure.CosmosRepository adapter for SelmanMade.OptionPatch. Converts PatchEntry lists into an IPatchOperationBuilder<T> action.

SelmanMade.OptionPatch.Cosmos

Azure Cosmos DB SDK adapter for SelmanMade.OptionPatch. Converts PatchEntry lists into Cosmos DB PatchOperation lists.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 108 8/14/2026
1.0.0 126 3/9/2026

Option<T> now binds from JSON out of the box via a built-in System.Text.Json converter — patch DTOs work directly as ASP.NET Core action parameters with no registration.
Getter-only collection and dictionary properties are patched in place instead of being skipped.
Newtonsoft [JsonProperty] names are honoured when resolving ToPatchEntries paths (the Cosmos DB SDK v3 default serializer).
New PATCH003 diagnostic for init-only / record targets, replacing a confusing CS8852 inside generated code.