SelmanMade.OptionPatch
1.0.0
dotnet add package SelmanMade.OptionPatch --version 1.0.0
NuGet\Install-Package SelmanMade.OptionPatch -Version 1.0.0
<PackageReference Include="SelmanMade.OptionPatch" Version="1.0.0" />
<PackageVersion Include="SelmanMade.OptionPatch" Version="1.0.0" />
<PackageReference Include="SelmanMade.OptionPatch" />
paket add SelmanMade.OptionPatch --version 1.0.0
#r "nuget: SelmanMade.OptionPatch, 1.0.0"
#:package SelmanMade.OptionPatch@1.0.0
#addin nuget:?package=SelmanMade.OptionPatch&version=1.0.0
#tool nuget:?package=SelmanMade.OptionPatch&version=1.0.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
- Step 3: Apply Patches to Entities
- Step 4: Generate Patch Entries (for Cosmos DB, JSON Patch, etc.)
- Nested DTOs
- Cosmos DB Integration
- 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"
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
};
Cosmos DB Integration
PatchEntry is deliberately free of any Cosmos DB dependency. You write a thin adapter in your API layer:
using Microsoft.Azure.Cosmos;
using OptionPatch;
public static class CosmosPatchAdapter
{
public static IReadOnlyList<PatchOperation> ToPatchOperations(
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 = CosmosPatchAdapter.ToPatchOperations(entries);
await container.PatchItemAsync<Person>(id, partitionKey, ops);
// Sends: Set(/name, "Alice"), Remove(/address)
This same pattern works for any patch target — JSON Patch (RFC 6902), MongoDB updates, or a custom format. Write one adapter per target.
JSON Property Name Support
The generator resolves JSON property names for ToPatchEntries paths using this order:
[JsonPropertyName("...")]on the entity property — if present, uses the specified name- 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.
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 |
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
This version does not yet handle:
- Collections or dictionaries
- Custom property name mappings on the DTO side
- Immutable record targets
- Constructor-based target creation
- Cycle detection in nested DTOs
System.Text.Jsonconverter for deserializingOption<T>from JSON
| 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. |
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 110 | 3/9/2026 |