Strong types for .NET 10: Option and Result types that cannot be misused, and value objects
that are written as one line and generated at compile time.
public readonly partial record struct OrderId : IValue<Guid>;
public readonly partial record struct Email : IValidatedValue<Email, string, EmailFault>
{
public static Result<Email, EmailFault> Create(string value) =>
value.Contains('@') ? new Email(value.Trim()) : Result.Error(EmailFault.NoAt);
}That is the whole declaration. The generators add the field, the constructor, Value, the
factories, value equality, a JSON converter, IParsable, IFormattable, IComparable, a
TypeConverter and the companions that make .Value work inside an EF Core query. Every way into
Email, whether JSON, a route parameter or a call to TryFrom, applies the one Create you wrote.
A value object can wrap a primitive, a Guid, a date, an enum, a Uri, a NodaTime type or a type of
your own, and it can be declared inside another type.
| Package | Role | Metalama |
|---|---|---|
| CodoMetis.TypeKit | Option<T>, Result<T, TError>, Result<TError>, the value-object contracts, and the analyzers that guard them |
no |
| CodoMetis.TypeKit.Analyzers | CMTK0001–CMTK0009: no default of a value object, an Option or a Result, written in source or handed out by FirstOrDefault and its kind; no value object nobody generates; no ignored result; no validation bypass; no comparing two value objects' values. In Razor components too. Arrives with the base package |
no |
| CodoMetis.TypeKit.Generators | The compile-time generation, in the project that declares value objects | yes |
| CodoMetis.TypeKit.EntityFrameworkCore | Value objects as columns with nothing registered per type, and .Value in LINQ |
no |
| CodoMetis.TypeKit.AspNetCore | Value objects in the OpenAPI document, with the schema of the type they wrap | no |
The base package is the cheapest one: someone who only wants Option and Result never receives a
code generator by accident. Taking Metalama on is a named choice, made once, in the domain project,
and it needs no Metalama license key: the generators build on Metalama's Open Source edition, in this
repository and in yours.
The EF Core and ASP.NET Core packages work at run time against the interfaces, so a host that maps
value objects from a referenced domain assembly needs no generator itself.
dotnet add package CodoMetis.TypeKit.Generators # the domain project: brings CodoMetis.TypeKit along
dotnet add package CodoMetis.TypeKit.EntityFrameworkCore # the data project, if there is oneusing CodoMetis.TypeKit;
using CodoMetis.TypeKit.ValueObjects;
public enum EmailFault { Blank, NoAt }
public readonly partial record struct Email : IValidatedValue<Email, string, EmailFault>
{
public static Result<Email, EmailFault> Create(string value)
{
if (string.IsNullOrWhiteSpace(value)) return Result.Error(EmailFault.Blank);
if (!value.Contains('@')) return Result.Error(EmailFault.NoAt);
return new Email(value.Trim());
}
}var id = OrderId.New(); // a version 7 Guid
Result<Email, EmailFault> created = Email.Create(input); // the fault says which rule refused it
Option<Email> maybe = Email.TryFrom(input); // is it valid?
Email known = Email.FromKnownGood("[email protected]"); // a literal you vouch for; throws otherwise
string reply = created.Match(
email => $"Welcome, {email}",
fault => $"Please check the address ({fault})");
JsonSerializer.Serialize(known); // "[email protected]"
JsonSerializer.Deserialize<Email>("\"nobody\""); // JsonException naming Email and NoAt, never the text
Email.Parse("[email protected]", null); // IParsable, so it binds as a route or query parameterservices.AddDbContext<ShopDb>(options => options.UseNpgsql(connectionString).UseTypeKit());
db.Customers.Where(c => c.Email.Value.EndsWith("@example.com")); // WHERE c."Email" LIKE '%@example.com'
services.AddOpenApi(options => options.AddTypeKit()); // OrderId: {"type":"string","format":"uuid"}- No
.ValueonOptionorResult. The content is reached throughMatch,TryGetValueand the combinators, so absence and failure are handled where the value is used. AdefaultOptionisNone; adefaultResultis uninitialized and every branching member throws on it rather than inventing adefault(TError).ToString()never prints the content. - Not wire types. Serializing an
Optionor aResultwith System.Text.Json throwsNotSupportedException, in both directions, instead of writing{}that reads back asNone. A serialized shape says absent with a nullable,ToOption()andOrNull()convert at the boundary, and a converter you register on the options takes precedence if you want a wire format of your own. - One rule set per validated value object.
Createis the only factory written by hand, and the generatedTryFrom,FromKnownGood, JSON converter, parsing and type converter all apply it. The fault you return fromCreateis what each of them reports: theJsonException, theFormatExceptionand theFromKnownGoodexception all name yourEmailFault.NoAt. The two validation-free paths, reading a database column and reading JSON the application stored itself, are explicit, named, and unreachable from input. - No factory throws on input by accident. A validated value object has no
From. Its ways in areCreate, which returns aResult,TryFrom, which returns anOption, andFromKnownGood, whose name says the caller vouches for the value and whose exception blames the call site. - A refusal never echoes the input. A JSON or parsing refusal names the value object and the
rule, or the wrapped type it could not read, and carries no inner exception that quotes the text;
FromKnownGoodnames the caller's expression, andOptionandResultprint nothing. So a value that is a secret does not reach a message or a log through this package, as long as your fault type does not carry it. - Swapping a primitive for a value object changes no contract. Its JSON is the wrapped type's, byte for byte, under your options; its column is the wrapped type's, a key over an integer still an identity column; its OpenAPI schema is the one ASP.NET publishes for the wrapped type. Only what the value object refuses changes.
- Loud failures. A declaration that cannot be generated is a build error naming the
declaration, CMTK1000 to CMTK1012, never a type with nothing in it. The analyzers make
defaultof a value object, anOptionor aResultan error. - Discovery by interface. The EF Core and OpenAPI satellites recognise a value object by the
attribute the generators put beside
IValueObject<,>, whose type arguments are constrained to it, never by a name, a namespace or an assembly prefix, and never byGetInterfaces(), which trimming breaks. - Native AOT. The run-time packages build with the trim and AOT analyzers on, and a consumer with value objects, source-generated JSON, OpenAPI and EF Core is published with Native AOT and run on every CI build. Each package's README says what, if anything, Native AOT asks of you.
Good libraries exist for both halves of TypeKit, and some needs are better met by one of them. What follows was read from each library's source at the release named, on 28 September 2026, including the next major where one is in preview. If something here is wrong or has changed, please open an issue.
- You build on .NET 10 with System.Text.Json, EF Core and ASP.NET Core, and want value objects that
change no contract: the JSON, the column and the OpenAPI schema stay the wrapped type's, with nothing
registered per type, and
.Valueworks inside a LINQ query. - You want one rule set per value object: the
Createyou write is what JSON, parsing, route binding,TryFromandFromKnownGoodapply, and a value object is declared by implementing an interface that generic code and the satellites can use, not by an attribute. - You want an
Optionand aResultthat cannot be misused: no.Value, nodefaultin source, an ignored result reported, no silent{}on the wire. - You publish with Native AOT.
| If you need | Consider | Because |
|---|---|---|
| .NET Framework, .NET Standard or a .NET before 10 | Vogen for value objects; CSharpFunctionalExtensions, ErrorOr or FluentResults for results | TypeKit targets .NET 10 only; these ship for .NET Standard 2.0 |
| Newtonsoft.Json, Dapper, LINQ to DB, MongoDB, Orleans, ServiceStack or protobuf-net for value objects | Vogen | It ships an opt-in integration for each. TypeKit covers System.Text.Json, EF Core and ASP.NET Core |
| A plain Roslyn source generator in the build rather than Metalama | Vogen or Thinktecture.Runtime.Extensions | TypeKit's generators run on Metalama (open source, no license key) in the project that declares value objects |
| Value objects of several members, smart enums or discriminated unions | Thinktecture.Runtime.Extensions | It has [ComplexValueObject], [SmartEnum<T>] and [Union]. A TypeKit value object wraps exactly one value |
| Several errors per failure, of typed kinds | ErrorOr | An ErrorOr<T> carries a list of errors. A TypeKit Result carries one TError, which can be a list of your own |
| Errors with reasons, success messages and causes | FluentResults | Its results carry reasons, errors and successes, and an error can name what caused it |
A functional-programming framework: Either, Fin, effects, immutable collections |
LanguageExt (4.4.9; 5.0 in beta) | TypeKit's Option and Result are deliberately small, with no effect or validation types |
| Unions of arbitrary types | OneOf | OneOf<T0, T1, …> is general-purpose; TypeKit has only Option and Result |
A Result on the wire |
CSharpFunctionalExtensions | It ships opt-in System.Text.Json converters for its Result. TypeKit refuses to serialize Option and Result, by design |
| Only your own closed set of outcomes, on .NET 11 | C#'s union types | They arrive with .NET 11 |
| Years of production use behind the library | Any of the above | TypeKit 1.0.0 is new |
| TypeKit 1.0.0 | Vogen 8.0.7 (9.0.0-beta.2 the same) | Thinktecture.Runtime.Extensions 10.5.0 | StronglyTypedId 1.0.0-beta08 | |
|---|---|---|---|---|
| Declared by | Implementing IValue<T> or IValidatedValue<…> on a partial record |
[ValueObject<T>] on a partial type |
[ValueObject<T>], [ComplexValueObject] or [SmartEnum<T>] on a partial type |
[StronglyTypedId] on a partial struct |
| Validation | Create returns a Result, and every generated way in applies it |
Validate returns a Validation; From throws |
A ValidateFactoryArguments partial method |
None |
| EF Core | UseTypeKit() maps every value object, nothing per type; .Value in a query translates to the column |
A generated converter per value object, which you register; comparing with a primitive in a query is an error (VOG034) | Packages per EF Core version (8, 9, 10) | Through a template of your own |
| OpenAPI | The schema ASP.NET publishes for the wrapped type | A generated schema transformer, type and format from a built-in table; Swashbuckle support | Swashbuckle filters | None built in |
| JSON | System.Text.Json, the wrapped type's JSON byte for byte | System.Text.Json by default; Newtonsoft.Json and more, opt-in | System.Text.Json, Newtonsoft.Json, MessagePack | System.Text.Json (more through templates) |
| Targets | net10.0 |
netstandard2.0, .NET Framework projects included |
net8.0, net9.0 |
netstandard2.0 |
| Generated by | Metalama, in the declaring project | A Roslyn source generator | Roslyn source generators | A Roslyn source generator |
| License | MIT | Apache-2.0 | BSD-3-Clause | MIT |
| TypeKit 1.0.0 | LanguageExt 4.4.9 (5.0.0-beta-77) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | |
|---|---|---|---|---|
| Reaching the value | Match, TryGetValue and the combinators; no .Value |
Match, IfNone and the like; no public .Value on Option |
.Value, which throws on a failure |
.Value, which returns default on an error |
A default instance |
Option: None. Result: throws when used, and is an error in source (CMTK0001) |
Option: None |
Maybe: none. Result<T>: a success holding default(T) |
A success holding default(T) |
| An ignored result | Reported (CMTK0003) | No analyzer | No analyzer of its own | No analyzer |
| System.Text.Json | Refused in both directions, rather than written as {} |
No converter of its own | Opt-in converters | No converter for the type |
| Targets | net10.0 |
netstandard2.0 (5.0 beta: net10.0) |
netstandard2.0, net6.0, net8.0 |
netstandard2.0, net8.0, net10.0 |
The value-object generation exists in this form because of Metalama.
A transitive fabric finds every type that implements a TypeKit contract, in the project that
references CodoMetis.TypeKit.Generators and in every project that references that one, so a value
object is declared by implementing its interface, with no attribute and nothing registered.
Templates write the generated members as ordinary C#, which a build can write out for you to read
(Reading the generated code).
A declaration that cannot be generated fails the build with its name on it, CMTK1000 to CMTK1012,
instead of compiling to less than it appears to. And Metalama runs the analyzers on the code it
transformed, which is how they reach Razor components.
Metalama is in CodoMetis.TypeKit.Generators alone. CodoMetis.TypeKit, the analyzers and both
satellites do not reference it, so a project that only wants Option and Result never receives
it. It is open source under the MIT license, and building with it needs no license key, here or in
your projects. Thanks to the Metalama team.
1.0.0 is the first release. The five packages share one version and follow Semantic Versioning: the public API, the generated members of a value object, and the analyzer ids and severities are the contract. A new analyzer rule arrives as a warning or a suggestion in a minor version, and becomes an error only in a major one. CHANGELOG.md lists what each version changed. The plan, the decisions and their evidence are in docs/plan.md, and the measurements that decided the design are in spikes/.
dotnet build CodoMetis.TypeKit.slnx
dotnet test --solution CodoMetis.TypeKit.slnxThe SDK is pinned in global.json. The PostgreSQL round-trip tests start a container, so they need
Docker; everything else runs without it. CONTRIBUTING.md describes the quality
bar, SECURITY.md how to report a vulnerability, and AGENTS.md is the
guide for coding agents working in this repository. Much of the code was written with AI assistance,
under the maintainer's direction and review; CONTRIBUTING.md
says what that does and does not mean.