SelmanMade.OptionPatch
1.1.0
dotnet add package SelmanMade.OptionPatch --version 1.1.0
NuGet\Install-Package SelmanMade.OptionPatch -Version 1.1.0
<PackageReference Include="SelmanMade.OptionPatch" Version="1.1.0" />
<PackageVersion Include="SelmanMade.OptionPatch" Version="1.1.0" />
<PackageReference Include="SelmanMade.OptionPatch" />
paket add SelmanMade.OptionPatch --version 1.1.0
#r "nuget: SelmanMade.OptionPatch, 1.1.0"
#:package SelmanMade.OptionPatch@1.1.0
#addin nuget:?package=SelmanMade.OptionPatch&version=1.1.0
#tool nuget:?package=SelmanMade.OptionPatch&version=1.1.0
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
- Quick Start
- Step 1: Define Your Entities
- Step 2: Define Patch DTOs
- Binding from JSON
- Step 3: Apply Patches to Entities
- Step 4: Generate Patch Entries (for Cosmos DB, JSON Patch, etc.)
- Nested DTOs
- Collections and Dictionaries
- Integrations
- JSON Property Name Support
- Diagnostics
- How It Works
- Limitations
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:
[JsonPropertyName("...")](System.Text.Json) on the entity property[JsonProperty("...")]or[JsonProperty(PropertyName = "...")](Newtonsoft.Json) on the entity property- camelCase fallback — converts the C# property name (e.g.
ZipCode→zipCode)
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:
- Scans for classes annotated with
[GeneratePatchApplier(typeof(T))] - Matches each
Option<T>property by name to the target entity's properties - Emits a
{DtoName}PatchApplier.g.csfile with two methods:Apply(patch, target)— direct property assignment on the entityToPatchEntries(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.Applyassigns properties after construction, so aninitaccessor can't be used; there is nowith-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 | Versions 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. |
-
.NETStandard 2.0
- System.Text.Json (>= 8.0.5)
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.
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.