CodoMetis.TypeKit
2.2.0
Prefix Reserved
dotnet add package CodoMetis.TypeKit --version 2.2.0
NuGet\Install-Package CodoMetis.TypeKit -Version 2.2.0
<PackageReference Include="CodoMetis.TypeKit" Version="2.2.0" />
<PackageVersion Include="CodoMetis.TypeKit" Version="2.2.0" />
<PackageReference Include="CodoMetis.TypeKit" />
paket add CodoMetis.TypeKit --version 2.2.0
#r "nuget: CodoMetis.TypeKit, 2.2.0"
#:package CodoMetis.TypeKit@2.2.0
#addin nuget:?package=CodoMetis.TypeKit&version=2.2.0
#tool nuget:?package=CodoMetis.TypeKit&version=2.2.0
CodoMetis.TypeKit
The base package of TypeKit. It gives you Option<T> and Result<T, TError> that you can't misuse,
and the interfaces that value objects and closed sets (smart enums) are generated from. It brings no
code generator and no Metalama, only the types and the analyzers that check them.
What it does for you:
- A value or nothing, a value or an error.
OptionandResulthave no.Value, so you handle absence and failure where you use the content. - One model for validation and outcomes. A value object's
Createreturns aResult. Its error (TypeKit calls it a fault) is the error the rest of your application handles, the EF Core and ASP.NET Core packages included. - Merge patches.
Patch<T>tells "absent", "null" and "a value" apart in a PATCH body. - Text guards.
AsText()andAsIdentifier()clean and check text a person wrote, or a value another system issued. - Mistakes caught at build time. The analyzers arrive with this package. They flag a
defaultvalue, an ignored result and skipped validation.
dotnet add package CodoMetis.TypeKit
| You want | Reference |
|---|---|
Option and Result only |
CodoMetis.TypeKit |
| Value objects generated from a one-line declaration | CodoMetis.TypeKit.Generators as well |
Value objects as EF Core columns, with .Value in queries |
CodoMetis.TypeKit.EntityFrameworkCore in the data project |
| Value objects in an OpenAPI document | CodoMetis.TypeKit.AspNetCore in the host |
Option<T>
A value that may be absent. It has no .Value, so you get the content out where you handle absence.
using CodoMetis.TypeKit;
Option<Customer> customer = repository.FindByEmail(email); // Some(...) or None
string greeting = customer.Match(
c => $"Welcome back, {c.Name}",
() => "Welcome");
if (customer.TryGetValue(out var found))
found.RecordVisit();
Option<string> town = customer
.Bind(c => c.Address) // Option<Address>, itself optional
.Map(a => a.Town)
.Filter(t => t.Length > 0);
var name = from c in customer where c.IsActive select c.Name; // query syntax works too
Option<decimal> discount = // a later from sees the earlier ones
from c in customer
from rate in discounts.GetValueOrNone(c.Tier) // TryGetValue as an option
select rate;
Create one
Option.Some(value)andOption.None()create one.ToOption()lifts a nullable.Option.None()takes its type from where it goes, such asreturn Option.None();or a conditional besideSome. Where nothing supplies a type, as withvar, writeOption.None<T>().Some(null)throws, so an option that reports a value always has one. Adefault(Option<T>)isNone.
Find one in a collection
GetValueOrNone(key)looks a key up in a dictionary.FirstOrNone()andLastOrNone()take the first or last element of a sequence, with or without a predicate.SingleOrNone()returns the only element, orNonefor none. It throws for more than one, asSingleOrDefaultdoes, because many is a defect and not absence.ElementAtOrNone(index)returns the element at an index. On a sequence,^1works too.Choose(selector)keeps the values a selector returnsSomefor, in order and without theNones. It filters and maps in one step, over elements that may be null (nicknames.Choose(n => n.ToOption())).Choose()keeps the values a sequence of options holds.
On a database query, FirstOrNone, FirstOrError, SingleOrNone and ElementAtOrNone ask the
provider for only the element they need, and let it run the predicate (a WHERE and a LIMIT in
SQL). LastOrNone, LastOrError and an index from the end don't compile there, because a query has
no last element until it is ordered.
Write the predicate as a lambda at the call, which makes it an expression. A Func<T, bool> variable
or a method group is no expression. It binds to the sequence's overload, which reads every row and
runs the condition in memory. Hold an Expression<Func<T, bool>> instead.
Get the value out
Or(fallback)unwraps with a fallback.OrDefault()unwraps withdefault.OrNull()unwraps into a nullable:int?for anOption<int>,string?for anOption<string>.ToResult(error)turns absence into an error.Traversevalidates an optional input when it is present.dto.Phone.ToOption().Traverse(Phone.Create)is aResult<Option<Phone>, PhoneFault>.Transpose()turns anOption<Result<...>>inside out, and aResult<Option<...>>back.
ToString() never prints the content, so an option is safe to log. The debugger shows it.
Continue a pending lookup
A lookup that is still running is a Task<Option<T>>. You continue it the way you continue a pending
result:
Result<Customer, SignInFault> active = await repository.FindByEmailAsync(email) // Task<Option<Customer>>
.FilterAsync(c => c.IsActive)
.TapAsync(c => audit.SeenAsync(c)) // waited for
.ToResultAsync(SignInFault.Unknown); // into a Result pipeline
MapAsync, BindAsync, FilterAsync, TapAsync and ToResultAsync continue a pending option. An
option also has MapAsync, BindAsync and TapAsync over callbacks that return a Task.
Good to know
- Arguments are checked first. Each one checks its delegates, and
ToResultAsyncits error, before the option is known. - A callback runs where the task it waits for completed. When that task was still pending, that isn't the caller's synchronization context. A pending result's callbacks behave the same way (below).
Result<T, TError> and Result<TError>
The outcome of an operation. Result<T, TError> holds a value or an error of your own type.
Result<TError> is for a command that produces no value, and holds a success or an error. Neither
has a .Value or an .Error.
public enum OrderFault { Empty, CustomerUnknown, Unpaid, Closed }
public Result<Order, OrderFault> Place(CustomerId customer, IReadOnlyList<Line> lines)
{
if (lines.Count == 0) return Result.Error(OrderFault.Empty);
if (!customers.Exists(customer)) return Result.Error(OrderFault.CustomerUnknown);
return new Order(customer, lines); // a bare value is a success
}
public Result<OrderFault> Cancel(OrderId id) =>
orders.Remove(id) ? Result.Success() : OrderFault.CustomerUnknown;
Return a bare value for a success, and Result.Error(...) for an error. If a bare error converted as
well, it would be ambiguous in the worst way: in a Result<long, int>, return 5; would pick the
error, because int is the closer match. Result<TError> has no value to confuse it with, so there
a bare error converts too.
var placed = service.Place(customer, lines);
IResult response = placed.Match(
order => Results.Created($"/orders/{order.Id}", order),
fault => Results.BadRequest(fault.ToString()));
if (placed.TryGetValue(out var order, out var fault)) { /* order is non-null here */ }
if (service.Cancel(id)) { /* a Result<TError> converts to bool, true for a success */ }
Result<Invoice, OrderFault> invoiced = placed.Bind(order => billing.Invoice(order));
Result<OrderFault> confirmed = placed.Bind(order => mailer.Confirm(order)); // a command after a query
Result<Order, ApiFault> forApi = placed.MapError(ApiFault.From); // across layers
Result<OrderFault> settled = service.Cancel(id) // a second chance:
.Recover(fault => fault == OrderFault.Closed ? Result.Success() : fault); // closed needs no cancelling
Option<Order> maybe = placed.ToOption();
Only Result<TError> converts to bool. On a result with a value, a bool would say whether the
operation succeeded where a reader expects the value. if (await users.IsEmailTakenAsync(email)) over
a Result<bool, DbFault> would take the branch for a success holding false. Match a valued result,
or compare its State.
Map,Bind,TapandTapAsyncrun only on success, and carry an error through unchanged.MapError,TapErrorandTapErrorAsyncrun only on an error.Recoverhands an error to its selector and returns what the selector returns for it: a success, or the error kept.Ensure(predicate, error)turns a success whose value breaks a rule into that error.
A lambda that returns a bare value on one branch and Result.Error(...) on the other needs its type
argument: placed.Bind<Invoice>(o => o.IsPaid ? invoices.Of(o) : Result.Error(OrderFault.Unpaid)).
C# infers a lambda's return type from its body alone.
placed.Ensure(o => o.IsPaid, OrderFault.Unpaid).Map(invoices.Of) says the same without one.
Good to know
- A
defaultresult throws. An array slot or an unassigned field can still produce adefault(Result<...>). ItsStateisResultState.Uninitialized, and every member that would pick a branch throwsInvalidOperationExceptionon it, rather than invent adefault(TError). ToString()never prints the value or the error.
Chain steps
You chain asynchronous steps without an await on each. Map, Bind, MapError, Recover, Tap,
TapError and Ensure also continue a Task<Result<...>> (MapAsync, BindAsync, ...). You await
the chain once, at its end, and Match follows that await.
Result<OrderFault> charged = await orders.FindAsync(id) // Task<Result<Order, OrderFault>>
.EnsureAsync(order => order.IsOpen, OrderFault.Closed) // a rule, on a pending result
.MapAsync(order => order.Total) // a synchronous step
.TapAsync(total => log.Charging(total))
.BindAsync(total => payments.ChargeAsync(total)) // a command: Result<OrderFault> remains
.TapErrorAsync(fault => log.NotCharged(id, fault));
Result<Quote, OrderFault> quote = // query syntax: each step sees the earlier ones
from item in catalog.Find(sku)
from price in pricing.For(item, customer)
select new Quote(item, price);
Result<Line, OrderFault> line = product.Zip(quantity, (p, q) => new Line(p, q));
Result<IReadOnlyList<Sku>, SkuFault> skus = input.Skus.Traverse(Sku.Create);
Result<IReadOnlyList<Sku>, SkuFault> all = parsed.Sequence();
Zip (two to six results), Traverse and Sequence stop at the first error, in argument or
sequence order. Traverse calls nothing after it. None of them collects errors.
TraverseAsync awaits one operation at a time, in order. It starts none after an error, or once the
cancellation token you give it is cancelled. For operations that may all run at once, use
Task.WhenAll and Sequence().
The asynchronous steps take callbacks that return a Task, and wait for them.
.TapErrorAsync(fault => audit.RecordAsync(fault)) has recorded the fault when the chain's await
returns, and what it throws comes out of that await.
Good to know
- A
ValueTaskcallback needs.AsTask(), or anasynclambda. Without it,TapAsyncandTapErrorAsyncwould bind it to their synchronous overload and not wait for it, andMapAsyncwould hold theValueTaskas the value. - Nothing resumes on your synchronization context. So the package's awaits never deadlock code that blocks on a chain.
- A callback runs where the task it waits for completed. When that task had already completed, it runs at once, on the caller's context. When it was still pending, it runs without that context, usually on a thread-pool thread. In a UI or a Blazor component, await the task and continue with the synchronous combinators (where a callback runs).
An exception as an outcome
Without help, the only way from a call that throws into a Result is a try/catch you write by
hand. Result.Catch runs the call and returns its value, or the exception as the error.
Result<int, Exception> parsed = Result.Catch(() => int.Parse("42")); // Success: 42
Result<int, Exception> refused = Result.Catch(() => int.Parse("x")); // Error: FormatException
Result<int, FormatException> narrowed = Result.Catch<int, FormatException>(() => int.Parse("x")); // only that type; any other propagates
Result<Exception> copied = Result.Catch(() => File.Copy(source, target)); // a command: a success, or the exception
Result<byte[], Exception> fetched = await Result.CatchAsync(() => client.GetByteArrayAsync(url)); // waits for the task; its exception is the error
Catch<T, TException>catches that type only. Every other exception propagates, so the error type says what was expected to fail. Spell both type arguments, since C# infers all or none.Catch<FormatException>(() => { ... }), with one type argument, is the command form, for a call without a value. Given a call that returns a value, which it would discard, it doesn't compile, and the error names the valued form.CatchAsyncawaits the task, so an exception thrown after its firstawaitis the error too. The error is whatawaitthrows. For a task that faulted with several exceptions, asTask.WhenAll's does, that is the first of them, while the task'sExceptionholds them all. Hold the task where every failure matters.- A cancellation passes through. An
OperationCanceledExceptionpasses through all of them, because a cancellation is no outcome, andRecoverwould run on it as on a failure. So does anAggregateExceptionthat holds only cancellations, asWait()and.Resultthrow for a cancelled task. One that holds anything beside them is the error, as it was thrown. - A timeout is the error.
HttpClientreports itsTimeoutelapsing as anOperationCanceledExceptionover aTimeoutException. That is the call failing, so it is the error, wrapped or not,.Resulton such a call included. To catch the timeout alone, nameTaskCanceledExceptionorOperationCanceledExceptionin a narrowedCatch. - A task goes to
CatchAsync. A delegate that returns aTask, anasynclambda among them, given toCatchor its command form doesn't compile, and the error namesCatchAsync. The task's exception would be thrown afterCatchhad returned. - A
ValueTask, or a task spelled as the type argument (Catch<Task<int>, ...>), is rejected when called, before the delegate runs.CatchAsynctakes aValueTaskthrough.AsTask()or anasynclambda, as above. - A delegate that returns null throws, as
Result.Success(null)does. - The other members don't catch.
Map,Bindand the rest are unchanged: a selector that throws propagates. - An exception is for the code that called, not for a client.
ToHttpResult()doesn't accept aResult<T, Exception>. UseMapErrorto turn the exception into a fault that it does accept.
A fault with a sentence
A fault is one of a fixed set, an enum's member or a closed set's. What varies per occurrence travels
beside it, in a Fault<TFault>: the fault as its Reason, and an optional Detail.
Fault.Of(OrderFault.NotAmendable, "The order shipped on 3 May.")infers the type.Fault.Of(OrderFault.NotFound)makes one without a detail. A blank detail throwsArgumentException. Leave the detail out instead, and it isnull.- A bare fault doesn't convert to one, so comparing the two doesn't compile. Write
fault.Reason == OrderFault.NotFound. WithRulenames the rule that failed, where the reason covers several rules. The rule is an enum's or a closed set's member, kept as a qualified key:Fault.Of(AccountFault.EmailTaken).WithRule(EmailRule.Reserved)has theRuleEmailRule.Reserved.- CodoMetis.TypeKit.AspNetCore turns a
Result<T, Fault<TFault>>into a response as it does the fault, with the detail as the problem'sdetailand the rule as itsrule. - A closed set whose members are faults declares
IFaultSet<TSelf>, and aFaultKindand an optional description per member. The host derives each status from them. - Faults of several types. Where shared code decides some rejections for every boundary,
Fault<OrderFault, EditFault>holds a fault of either type, andFault<T1, T2, T3>one of any of three. A fault of each type converts, andMatchtakes a delegate per type.Fault<OrderFault, EditFault>.Of(reason)names the type where a fault becomes a result. C# needs that, because it is a second conversion.
JSON converters that say what they write
Two converters write a decimal in one spelling per value, its smallest scale:
DecimalNumberJsonConverter as a number, and DecimalStringJsonConverter as a string. They do so as
a value and as a dictionary key, and read either form. A value object over a decimal is written by
whichever one the options hold.
A converter that implements IDescribedJsonConverter says what it writes. JsonSchemaDescriptions
in the options describes a converter someone else wrote. With these, two tools see what is really
written: System.Text.Json's schema exporter, through DescribedJsonSchema.ExporterOptions(), and the
OpenAPI document, through CodoMetis.TypeKit.AspNetCore's AddTypeKit(). The
JSON
page shows both.
Not wire types
Option and Result never go over the wire. The one wire type in this package is Patch<T>, a
merge patch's field (below).
System.Text.Json rejects an Option, a Result, a Fault<TFault> and a fault of several types, in
both directions. A fault is matched to a response, as the result holding it is. The
NotSupportedException names the type and the alternative:
JsonSerializer.Serialize(new OrderDto(Option.Some(customer)));
// NotSupportedException: Option<Customer> is not a wire type. A serialized shape says absent with a
// nullable (Customer?), and ToOption() and OrNull() convert at the boundary. ...
Without this, the serializer would see no public members. It would write {} for a Some and read
it back as None, with nothing raised and the missing data as the only evidence. A Result would
write its State and read back uninitialized.
So a request, a response, a stored document or an event payload says absent with T?, and the
option lives between them: dto.Nickname.ToOption() on the way in, nickname.OrNull() on the way
out. You match a result to a response or a document. It has no wire shape of its own.
The markers Option.None(), Result.Success(...) and Result.Error(...) are rejected too, so an
endpoint that returns one fails on its first call rather than answering {}. So is a nullable of any
of them, an Option<T>? in a PATCH-style shape, whether it holds a value or not.
They aren't columns either. EF Core can't map an Option or a Result property, and says so when it
builds the model. An entity says absent with T? too.
Good to know
- Your own converter wins. A converter registered on the
JsonSerializerOptionstakes precedence over the rejection. To putOption<T>on the wire, write one and register it there, on those options only. - A missing property reaches no converter. The serializer leaves it
default: aNone, or an uninitialized result. Where absence must be an error too, mark the propertyrequired, or setRespectRequiredConstructorParameterson the options. - A source-generated context can't describe an
Option<T>?orResult<...>?property. It fails when it builds that shape's contract, before any document is read or written, with the serializer's ownInvalidOperationException:The converter '' is not compatible with the type 'CodoMetis.TypeKit.Option`1[System.String]'.The rejection can't be TypeKit's own there. That contract takes a converter typed forOption<string>alone, which onlyMakeGenericTypecould build for everyT. Declare the propertyT?.
Value objects
A value object wraps exactly one value, and equals another when the values inside are equal. You declare it in one line, and CodoMetis.TypeKit.Generators generates the rest at compile time.
using CodoMetis.TypeKit;
using CodoMetis.TypeKit.ValueObjects;
public readonly partial record struct OrderId : IValue<Guid>;
public enum CodeFault { Blank, TooShort, NotUpperCase }
public readonly partial record struct ProductCode : IValidatedValue<ProductCode, string, CodeFault>
{
public static Result<ProductCode, CodeFault> Create(string value)
{
if (string.IsNullOrWhiteSpace(value)) return Result.Error(CodeFault.Blank);
var trimmed = value.Trim();
if (trimmed.Length < 3) return Result.Error(CodeFault.TooShort);
if (trimmed != trimmed.ToUpperInvariant()) return Result.Error(CodeFault.NotUpperCase);
return new ProductCode(trimmed); // the private constructor is generated
}
}
This package holds the interfaces every value object is built on:
| Interface | What it means |
|---|---|
IValue<T> |
Wraps any T. Gets From(value). |
IValidatedValue<TSelf, T, TFault> |
You write Create, which returns a Result. Gets TryFrom(value), which returns an Option, and FromKnownGood(value), which throws and names the caller's expression, never the value. Every generated way to create one runs Create: JSON, parsing and the type converter. |
IValueObject<TSelf, T> |
What every generated value object implements: Value, equality. Run-time code recognises a value object by the attribute the generators add beside it, whose type arguments are constrained to this interface, never by name. |
IPlainValueObject<TSelf, T> |
A value object with no rules: From accepts any T. Plain value objects only. |
IValueObjectMaterializer<TSelf, T> |
Rebuilds an instance without validation, for values your application wrote itself, such as a database column. Implemented explicitly, so it isn't on the public surface. |
Pick the factory by what you need:
Createwhen the caller has to learn what to fix.TryFromwhen "is it valid?" is the whole question.FromKnownGoodfor a literal in your code, or a value your code has just produced.Fromwhen there are no rules.
OrderId.New() creates an identifier from a version 7 Guid. It is an extension in the
CodoMetis.TypeKit namespace, so import that in the file that calls it.
A validated value object has no From, so nothing that looks harmless throws on input. The one
factory that throws is FromKnownGood. Its name says the caller vouches for the value, and its
exception names the expression at the call site, never the value.
The fault Create returns is what every rejection reports. FromKnownGood's exception carries it,
and the generated JSON converter and parsing name it in their JsonException and FormatException.
Wherever a rejection happens, it names the type and the rule and never the input, which can be a
secret. Text the underlying type can't read at all is reported as that, without quoting it. The
fault's own text is yours, so keep the input out of it.
Generic code over value objects
The interfaces have static abstract members, so a method constrained on them works for every value
object. The generated types also implement IEqualityOperators, IComparisonOperators and
IMinMaxValue where the underlying type allows, so they satisfy generic-math constraints too.
OrderId.New() is such a method: one extension for every plain (unvalidated) value object over a
Guid, not a member generated per type.
static Option<TSelf> Read<TSelf, TFault>(string field)
where TSelf : IValidatedValue<TSelf, string, TFault>
where TFault : notnull =>
TSelf.Create(field).ToOption();
static TId NewId<TId>() where TId : IValueObject<TId, Guid>, IPlainValueObject<TId, Guid> =>
TId.From(Guid.CreateVersion7());
Stored JSON
The generated JSON converter runs Create. That is right for input, and wrong for documents your
application stored before a rule existed. Register StoredJsonConverterFactory on that store's
options, and nowhere near input:
var storeOptions = new JsonSerializerOptions { Converters = { new StoredJsonConverterFactory() } };
Your own no-default structs
[RequireCustomInitialization("Use Money.Of(...)")] on a struct makes the analyzer reject default
and new() of it, as it does for Option, Result and every value object.
Written text
Most applications trim what a person typed and reject a blank. Few reject a zero-width space, a
Hangul filler or NUL, which IsNullOrWhiteSpace lets through and Trim leaves in place. Fewer
compose the text. So a name written with the single character U+00FC (u with diaeresis) and the
same name written with u and a combining diaeresis become two rows of a unique index that look
alike. Two extensions on string handle all of it and return a Result:
var name = " Jean\u00A0Paul ".AsText(); // Success: Jean Paul, trimmed, the no-break space folded to an ordinary one
var subject = "subject\u200Bid".AsIdentifier(); // Error: Hidden, a zero-width space
var missing = ((string?)null).AsText(); // Error: Blank
Who chose the value decides which one you call:
AsText()is for one line of text a person wrote, such as a name or a title. It trims, folds every space separator to an ordinary space, and composes to NFC (not NFKC, so a ligature or a fullwidth letter stays as written). It rejects a character that renders as nothing or breaks the line. It keeps the two zero-width joiners, because they spell Persian, Arabic and Indic scripts and hold an emoji sequence together.AsIdentifier()is for a value another system issued, such as a subject claim, a session id or a key. It trims and changes nothing else, since an issued value is compared against what its issuer sends next time. It rejects the joiners too.
Both reject these as Hidden, once the surrounding whitespace is trimmed:
- Control characters: NUL, a tab, a line feed, a carriage return, and the rest of the C0 and C1 ranges. A multi-line field, such as a comment, goes through neither guard.
- Format characters: the soft hyphen, the zero-width space, the word joiner, the byte-order mark, the bidirectional marks, embeddings, overrides and isolates (so an Arabic name ending in a right-to-left mark is rejected), the invisible operators and the tag characters. Not the Arabic number sign, the end of ayah and the other prepended concatenation marks, which are drawn, even with nothing after them.
- Private-use characters, and the line and paragraph separators.
- Broken encodings: an unpaired surrogate, and
U+FFFE, a byte-order mark read in the wrong byte order.string.Normalizethrows on these, and the guards reject them instead. - Characters that render nothing, whatever their category: the combining grapheme joiner, the Hangul fillers, the Braille pattern blank, the Khmer inherent vowels and the Khitan filler.
- A misplaced variation selector, anywhere but after the character it varies (an emoji's presentation, an ideograph's variant).
- More than 30 combining marks in a row. No script stacks that many, and composing a run reorders it in time that grows with the square of its length.
- A value of joiners, variation selectors and combining marks alone, which renders no character of its own.
Both scan by Rune, so a character above the BMP is seen for what it is.
AsText() needs the runtime's globalization data to compose. In globalization-invariant mode it
throws (why), so written text has no guard there.
AsIdentifier() composes nothing and works there, for the values it is for.
TextFault has two members, because you decide what a blank means. A required field makes Blank
an error. An optional one makes it absence: AsOptionalText() and AsOptionalIdentifier() return a
Result<Option<string>, TextFault>. Hidden is a rejection in both. A null is Blank, since the
extension is the door a bound request property goes through, and binding enforces no
non-nullability.
A value object over either guard is one line. Name it for the field rather than for the guard:
Description says more than Text, and two fields of one type can be assigned to each other.
public readonly partial record struct Description : IValidatedValue<Description, string, TextFault>
{
public static Result<Description, TextFault> Create(string value) =>
value.AsText().Map(text => new Description(text));
}
Good to know
- Unassigned code points are accepted, on purpose. The runtime's Unicode tables lag the standard, so a name spelled with a newly assigned character would be rejected until the SDK caught up.
- Confusable letters across scripts are accepted, on purpose. That is a comparison question, not a rejection.
Merge patches
A PATCH body in JSON Merge Patch (RFC 7396) says one of three things about a field:
- nothing, so it stays;
null, so it is cleared;- a value.
System.Text.Json can't tell the first two apart, because it calls no converter for a property it
doesn't find. So every PATCH endpoint rebuilt the difference by hand. Patch<T> is that field, and
the one wire type in this package, since Option rejects JSON. An option says whether a value
exists, a patch whether a request mentioned it.
public sealed record OrderPatch(Patch<string> Name, Patch<string> Nickname, Patch<int> Quantity);
var patch = JsonSerializer.Deserialize<OrderPatch>("""{"nickname":null,"quantity":2}""", JsonSerializerOptions.Web)!;
var name = patch.Name; // Absent, the field was not mentioned
var nickname = patch.Nickname; // Null, the field was null
var quantity = patch.Quantity; // Value: 2
default(Patch<T>) is Absent. It is the one default in this package that means something, and
the analyzers leave it alone. Applying a patch needs no reflection:
patch.Name.Tap(name => order.Name = name); // a clear cannot reach a required field
patch.Nickname.IfPresent(nickname => order.Nickname = nickname); // null clears an optional one
order.Quantity = patch.Quantity.Match(
quantity => quantity,
() => throw new InvalidOperationException("Quantity cannot be cleared."),
() => order.Quantity);
Reading never rejects a null. The Null state is part of the wire format whatever T is, and whether
a clear is accepted is your application's rule, which you state in Match.
So you never need a Patch<T?>. T is the value's type and notnull, as an option's is.
Patch<string> is a field that can be cleared. Patch<string?> is a warning (CS8714) at its
declaration, rather than a nullable handed to every Match, Map and Traverse though a value is
never null.
A value object inside a patch keeps its own rule. Patch<Email> given a value that Create rejects
is rejected as anywhere else, without quoting the input. Where the rejection should be the
endpoint's own fault, the request carries a Patch<string>, and Traverse validates it:
request.Email.Traverse(Email.Create) is a Result<Patch<Email>, EmailFault>, with a clear and an
absence passed through. Map changes a value the same way.
Good to know
- An absent patch can't be written, since a converter can't leave a property out. Give a property
that is written
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], and an absent patch is left out. - A source-generated context never sees
T, since the patch's converter hides it. A primitive, a string, aGuid, a date or aUriis read with the serializer's built-in converter, and a value object with its own. List any other type, a class or an enum, in the context, or the serializer rejects it with its own message naming it. - The OpenAPI document describes a patch property as
T's schema, not required, admitting null (CodoMetis.TypeKit.AspNetCore). - This isn't ASP.NET Core's JSON Patch (RFC 6902, a list of operations).
The analyzers
They arrive with this package, so every project that can see the interfaces gets the checks:
- CMTK0001:
default(OrderId),new OrderId(),new()anddefaultof a value object, anOption, aResultor a[RequireCustomInitialization]struct. Such an instance passed no factory. - CMTK0002: a type implements
IValue<T>,IValidatedValue<,,>orIClosedSet<TSelf>, but the project doesn't referenceCodoMetis.TypeKit.Generators, so nothing would be generated for it. - CMTK0003: a
ResultorOptionthat a call returns, dropped, waited for and forgotten, or walked by a loop that never reads it._ = ...says you mean it. - CMTK0004:
Materialize, which rebuilds a value object without its rules, called anywhere but the EF Core package. - CMTK0005 (warning):
new OrderId[n]and the like, which fill every slot with a default instance. - CMTK0006 (warning): a property or field of such a type that nothing sets.
requiredfixes it. - CMTK0007 (Info, a suggestion in the IDE):
FromKnownGoodgiven a parameter, which is input as far as the code can tell. - CMTK0008 (warning):
order.CustomerId.Value == product.Id.Value, the values inside two different value objects compared, which the types exist to prevent. - CMTK0009 (warning): a call that hands out a
defaultinstance when it finds nothing,ids.FirstOrDefault()orProductCode.TryFrom(s).OrDefault().FirstOrNone(),GetValueOrNone()andOr(fallback)say what happens then. - CMTK0010: a type argument that breaks what its parameter declares with
[Abstract],[Sealed],[Record],[Concrete],[Interface],[Unsealed],[ValueObject],[ClosedSet],[FlagsEnum]or[ReadOnlyStruct], the constraints C# has nowherefor, at any closed use. Also a pair of arguments for two parameters marked[Distinct]or[Unrelated]that are one type, or related. A method that implements an interface's method, or overrides one, carries the constraint of what it implements. - CMTK0011: a type under an interface or base class marked
[RequireParameterlessConstructor]without the parameterless constructor it asks for, which the host would look for by reflection. The code fix writes it. - CMTK0012: a closed set's
Matchgiven an argument by position, where reordering two members would swap their branches and still compile. The code fix names every argument. - CMTK0013: a closed set's member written outside its declaration, in the static constructor or another field's initializer, so it holds an instance named and listed as another member.
- CMTK0014 (warning): a
FaultKindor aConstructorAccessibilitythat names no member, such as a cast ordefault.
The rules also run in Razor components (.razor, .cshtml).
The ids stay the same across releases. The rule table is in CodoMetis.TypeKit.Analyzers.
Native AOT
This package works in a trimmed or Native AOT application. The packages are built with the trim and AOT analyzers on.
- One exception: the query helpers on EF Core.
FirstOrNone,FirstOrError,SingleOrNoneandElementAtOrNoneon an EF Core query compose the query where EF can't precompile it, and throw there. Write EF's operators at the call site and take the first element of the list. - List value objects in your JSON context. Native AOT requires source-generated JSON. List the
value objects (or the types that hold them) in your
JsonSerializerContext, not the types inside them. The generated converters build the underlying type's contract themselves, from a converter on your options, the type's own[JsonConverter], or the serializer's built-in one. Only a value object over a type of your own that has no converter needs that type in the context, and the serializer's error names it. - List the enum of an enum-backed value object too, if it should be written by name. A context's
UseStringEnumConverterdoesn't reach an enum the context doesn't list. OptionandResultreject JSON there exactly as on the JIT, andStoredJsonConverterFactoryreads without the rules.
Why does this throw?
Some calls throw on purpose, where carrying on would hand back a plausible wrong answer. Each of these exceptions ends its message with a link to its entry here: what it rejects, why, and what to call instead.
Option or Result in JSON
Thrown: NotSupportedException: Option<Customer> is not a wire type..., or
Result<Order, OrderFault> is an outcome, not a wire type.... System.Text.Json throws it when it
reads or writes an Option, a Result, a Fault<TFault> or a Fault<T1, T2>, a marker such as
Option.None(), or a nullable of any of them. Writing an absent Patch<T>, which no document can
carry, fails with the same link. Mark that property
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)].
Why: without it, a Some is written as {} and read back as None, and nothing is raised. See
Not wire types and the design.
Instead: say absent with T? in the shape, and convert at the boundary: dto.Nickname.ToOption()
on the way in, nickname.OrNull() on the way out. Match a result to a response. A source-generated
context rejects an Option<T>? property earlier, with the serializer's
The converter '' is not compatible with the type 'CodoMetis.TypeKit.Option`1[...]'. Declare T?
there too.
A null in an Option or a Result
Thrown: ArgumentNullException, from:
Option.Some(null),Result.Success(null)orResult.Error(null);- a
MaporMapErrorwhose selector returns null, and aPatch<T>.Mapwhose selector returns null, since a clear is the Null state, never a value; FirstOrNone()over a null element;- a null error given to
ToResult,EnsureorFirstOrError; CatchAsyncgiven a delegate that returns no task. A null can't be awaited, and it isn't an exception the call threw. Every other asynchronous combinator,MapAsync,BindAsync,TapAsync,TapErrorAsync,RecoverAsyncandTraverseAsync, rejects such a delegate the same way.
Why: an option that reports a value always has one, and a result's value and error are never
null. notnull isn't checked at run time, so the factories check it.
Instead: value.ToOption() turns a null into None. A selector that may return null becomes
Bind(c => c.Nickname.ToOption()).
An uninitialized Result
Thrown: InvalidOperationException, as one of:
This Result was never initialized...fromMatch,Map,TryGetValueand every other member that picks a branch;This Fault<OrderFault, ConcurrencyFault> holds no fault...fromMatchon a fault of several types;This Fault<OrderFault> holds no fault...where adefaultfault of a closed set converts to one.
Why: a default result is neither a success nor an error, and taking the error branch would
invent a default(TError). A default fault of several types holds a fault of none of them. Both come
from an array slot, a field nothing set, or a property missing from a JSON document.
Instead: create results through Result.Success/Result.Error or a conversion, and faults
through Fault.Of. CMTK0005 and CMTK0006 point at the arrays and fields, and required closes a
property. State tells an uninitialized result apart without throwing.
A value object refused a value
Thrown: JsonException: ProductCode refused the JSON value (TooShort)., or
FormatException: ProductCode refused the input (TooShort). from Parse and the type converter.
Why: every generated way to create a value object runs Create, so input the rules reject never
becomes a value object. The message names the rule and never the input, which can be a secret. See
Value objects.
Instead: where the caller should learn which rule failed, call ProductCode.Create(text) and
match the fault. Use TryFrom or TryParse where valid or not is the whole question. ASP.NET Core
answers the JsonException of a request body with 400.
FromKnownGood refused a value
Thrown: InvalidOperationException: ProductCode refused request.Code, which the call site declared known-good (TooShort).
Why: FromKnownGood is for a literal or a value your code has just produced, so a rejection
there is a bug at that call site. The message names the call site's expression and never the value.
Instead: send input through Create or TryFrom. CMTK0007 points at a FromKnownGood given a
parameter.
A value object could not read the input
Thrown: FormatException: Quantity could not read the input as Int32. from Parse and the type
converter, or JsonException: Quantity could not read the JSON value as Int32. from JSON.
Why: the underlying type's own message quotes the input ("The input string '...' was not in a correct format."). So TypeKit replaces it, and doesn't keep it as the inner exception either. See the design, decision 27.
A JSON error still carries its path. For a value object read as a dictionary key, the path ends in
the key itself ($.items.SE), and MVC uses the path as the error's name in its 400. Keep secrets out
of a dictionary's keys.
Instead: use TryParse for text that may not parse. If the text is yours to log, log it before
parsing.
A null given to a value object
Thrown: ArgumentNullException: ProductName wraps no null. from From, or
JsonException: ProductName cannot be read from a JSON null. from JSON.
Why: a value object is a value. Its Value promises never to be null, as an Option never holds
one. A string-backed value object would otherwise wrap the null it was given.
Instead: where the value can be absent, declare the property or parameter as ProductName?.
JSON then reads a null as null, and the value object is never asked to hold it.
A closed set has no member of that name
Thrown: FormatException: OrderStatus has no member of that name. from Parse and the type
converter. JSON throws a JsonException with the same message, also for a token that isn't a string.
Why: a closed set's members are its public static readonly fields. A name that matches none of
them, compared ordinally, is rejected. The message names the set and never the input.
Instead: TryParse returns false, Parse(name, ignoreCase: true) accepts any casing, and
All lists the names there are.
An instance that is not a member of its set
Thrown: InvalidOperationException: OrderStatus: this instance is not a member of the set, so it has no name. from Name, QualifiedName, the generated ToString(), Match, the JSON converter
and the EF Core column.
Why: a closed set's constructors are private, but the set's own code can still call one outside a member's declaration, in a static method or a private field. That instance has no name. Written as JSON or stored in a column, it would come back as nothing.
The same happens when a member's initializer or constructor, or any other static field initializer
of the set, reads a member's name, because the names are given after every static field is
initialized. There the exception surfaces as a TypeInitializationException, and the set can't be
used for the rest of the process.
Instead: wherever the set's code produces an instance, return one of the members, such as
OrderStatus.Open. Keep the data a member needs in its own constructor's arguments. Read a name in a
property or method, not in a static field initializer.
A closed set read before it is initialized
Thrown: InvalidOperationException: OrderStatus is still being initialized: a static field initializer of the set read All, or a member by name, before every member existed. from All,
Parse, TryParse and the JSON converter. It surfaces as a TypeInitializationException, and after
it the set can't be used for the rest of the process.
Why: a static field initializer of the set runs while its members are still being created. All
is filled once every member exists, and a member declared below the reading field is still null. So
public static readonly IReadOnlyList<OrderStatus> Snapshot = All; held null, for a list typed as
never null, and Parse handed out null for a later member.
Instead: read All or parse a name in a static property or method, which runs once the set is
initialized: public static IEnumerable<OrderStatus> Active => All.Where(status => status != Cancelled);.
In a field initializer, refer to a member by its field, Open, not by its name.
A task given to Catch
Thrown: ArgumentException: Catch was given a delegate whose value is a task..., from:
Catch, when its value's type is aTaskor aValueTask, spelled (Catch<Task<int>, HttpRequestException>) or inferred from a delegate that returns aValueTask;CatchAsync, when the task's value is another task.
Nothing has run: the delegate is rejected before it is called.
Why: the task's exception is thrown after Catch has returned, so the result would be a success
whatever the call does. A delegate that returns a Task is rejected at compile time. A ValueTask
can't be: it converts to no task type that an overload could name without making every async
lambda ambiguous. And a spelled type argument chooses its overload itself.
Instead: use CatchAsync, which awaits the task:
await Result.CatchAsync(() => stream.ReadAsync(buffer).AsTask()), or an async lambda. In
CatchAsync, await the inner task in the delegate.
AsText in globalization-invariant mode
Thrown: PlatformNotSupportedException: AsText() composes text to Unicode normalization form C, and this process cannot... from AsText(), whatever the string, in a process in
globalization-invariant mode. That mode is on with
<InvariantGlobalization>true</InvariantGlobalization> in the project, which Native AOT and
container templates often set, or with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 in the environment.
Why: in that mode string.Normalize returns its input unchanged. AsText() would accept a name
written with a combining diaeresis as a success, uncomposed, and one name would be kept in two
spellings that compare unequal. That is the defect the guard exists to prevent, and it would be
silent.
Instead: turn the mode off and make ICU available to the process. You need both, since ICU installed while the mode stays on does nothing:
- The mode off:
<InvariantGlobalization>false</InvariantGlobalization>, and noDOTNET_SYSTEM_GLOBALIZATION_INVARIANT. - ICU installed:
libicuon Debian and Ubuntu,icu-libson Alpine.
Written text has no guard in this mode. AsIdentifier() is no replacement: it is the guard for a
value another system issued. It rejects the zero-width joiners that Persian, Arabic, Indic scripts
and emoji sequences need, and it folds no space.
Materialize and CMTK0004
This one isn't a run-time exception but a build error. Materialize rebuilds a value object without
Create, for a value your application wrote itself. Called anywhere but the EF Core package, it
fails the build
(CMTK0004).
Use Create or FromKnownGood instead, and StoredJsonConverterFactory for JSON your application
stored.
Where things are
CodoMetis.TypeKit:Option,Result,OrderId.New(),AsText(),AsIdentifier(),Patch<T>,[RequireCustomInitialization],[RequireParameterlessConstructor], and the type-parameter attributes[Abstract],[Sealed],[Record],[Concrete]and their kind.CodoMetis.TypeKit.ValueObjects: every value-object interface.CodoMetis.TypeKit.ClosedSets:IClosedSet<TSelf>. Closed sets are generated by CodoMetis.TypeKit.Generators, whose README covers them.CodoMetis.TypeKit.CompilerServices: what only the generated code and the other TypeKit packages call, hidden from IntelliSense.
Targets .NET 10. Source and issues: github.com/CaffeinatedCoder/CodoMetis.TypeKit.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- CodoMetis.TypeKit.Analyzers (>= 2.2.0)
NuGet packages (5)
Showing the top 5 NuGet packages that depend on CodoMetis.TypeKit:
| Package | Downloads |
|---|---|
|
CodoMetis.TypeKit.AspNetCore
ASP.NET Core integration for CodoMetis.TypeKit: OpenAPI schemas for value objects, closed sets and merge patches, and a Result as an HTTP response with problem details and fault responses a client can act on. |
|
|
CodoMetis.TypeKit.EntityFrameworkCore
EF Core integration for CodoMetis.TypeKit: value-object and closed-set converters by convention, and .Value in LINQ queries. |
|
|
CodoMetis.TypeKit.Generators
Compile-time generation of value objects and closed sets (smart enums) for CodoMetis.TypeKit, built on Metalama: one declared Create is what JSON, parsing, route binding and TryFrom apply. |
|
|
CodoMetis.TypeKit.Serilog
Serilog integration for CodoMetis.TypeKit: value objects and closed sets are logged as what they print, so a destructured value object or a record holding one never exposes a value its ToString() masks. |
|
|
CodoMetis.TypeKit.Testing
Test assertions for CodoMetis.TypeKit's Option, Result and Patch: hand a test the value or the error it expects, or fail with what the outcome was instead. No test framework dependency. |
GitHub repositories
This package is not used by any popular GitHub repositories.