Skip to content

Tri-State Semantics and Optional<T>

HTTP PATCH operations need to distinguish three states: a field was not sent (leave unchanged), sent as null (clear the value), or sent with a value (update). Standard C# nullability cannot express “not sent” vs “null”. Optional<T> solves this with a tri-state struct.


Consider a PATCH request to update a product:

{ "name": "New Name" }

This should update Name but leave Description unchanged. But with a normal DTO:

public record UpdateProduct
{
public string? Name { get; init; } // "New Name"
public string? Description { get; init; } // null — but does this mean "clear" or "not sent"?
}

There is no way to tell if Description was explicitly set to null or simply absent from the JSON.


Optional<T> is a readonly struct with three states:

StateHasValueIsUndefinedValueMeaning
UndefinedfalsetruethrowsField was not in the JSON payload
NulltruefalsenullField was explicitly set to null
Valuetruefalsethe valueField was set to a specific value
Optional<string>.Undefined // Not sent
Optional<string>.Null // Sent as null
Optional<string>.Of("Alice") // Sent with value
Optional<string>.Of(null) // Same as Null
// Implicit conversion from T
Optional<string> name = "Alice"; // Of("Alice")
var name = Optional<string>.Of("Alice");
if (name.HasValue)
Console.WriteLine(name.Value); // "Alice"
// Safe access with default
string display = name.GetValueOrDefault("Unknown");
// Functional style
name.IfPresent(n => Console.WriteLine(n));
// Transform
Optional<int> length = name.Map(n => n?.Length ?? 0);

The OptionalConverterFactory handles JSON deserialization with correct tri-state mapping:

JSONDeserialized to
{ "name": "Alice" }Name = Optional.Of("Alice"), Email = Optional.Undefined
{ "name": null }Name = Optional.Null
{ }Name = Optional.Undefined, Email = Optional.Undefined

The converter is registered automatically on SG-generated patch types via [JsonConverter] attribute.


Mark a record with [GeneratePatch<TEntity>] and the SG generates a complete patch DTO:

[GeneratePatch<Product>]
public partial record PatchProduct;
public partial record PatchProduct
{
[JsonConverter(typeof(OptionalConverter<string>))]
public Optional<string> Name { get; init; }
[JsonConverter(typeof(OptionalConverter<string?>))]
public Optional<string?> Description { get; init; }
[JsonConverter(typeof(OptionalConverter<decimal>))]
public Optional<decimal> Price { get; init; }
public void ApplyTo(Product entity)
{
if (Name.HasValue) entity.SetName(Name.Value);
if (Description.HasValue) entity.SetDescription(Description.Value);
if (Price.HasValue) entity.SetPrice(Price.Value);
}
public IReadOnlySet<string> ModifiedProperties => /* tracks which properties were in the JSON */;
}

The generator automatically excludes:

  • Id, PersistenceId — identity cannot change
  • CreatedAt, CreatedBy, UpdatedAt, UpdatedBy — managed by auditing
  • IsDeleted, DeletedAt, DeletedBy — managed by soft delete
  • RowVersion — managed by concurrency
  • Collection navigations — not patchable via simple PATCH
  • Reference navigations — use explicit FK properties instead

[Endpoint(HttpVerb.Patch, "/api/products/{productId}")]
public partial class PatchProductEndpoint : Endpoint<ProductDto>
{
private IRepository<Product, Guid> _products;
private IUnitOfWork _uow;
[FromRoute] public required Guid ProductId { get; init; }
[FromBody] public required PatchProduct Patch { get; init; }
public override async Task<Result<ProductDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
var product = await _products.GetByIdAsync(ProductId, ct);
if (product is null)
return new NotFoundError("Product", ProductId.ToString());
Patch.ApplyTo(product);
await _uow.SaveChangesAsync(ct);
return ProductDto.FromEntity(product);
}
}
### Update only the name
PATCH /api/products/abc123
Content-Type: application/json
{ "name": "Updated Product" }
### Clear the description
PATCH /api/products/abc123
Content-Type: application/json
{ "name": "Updated", "description": null }
### No changes (valid but no-op)
PATCH /api/products/abc123
Content-Type: application/json
{ }

When ApplyTo() calls the entity’s Set*() methods, IChangeTracking.ModifiedProperties is updated automatically. This means:

  • Selective validation — only modified properties are validated
  • Optimized persistence — EF Core tracks only changed columns
  • Audit trailEntityPropertyChanged<T> events fire only for actual changes

IDSeverityDescription
PRAG1900Error[GeneratePatch<T>] type must be partial
PRAG1901ErrorEntity type could not be resolved
PRAG1902WarningEntity has no settable properties (patch would be empty)