Strong types for .NET 10, with one set of rules from the request to the database.
Value objects in one line
- Declare it, the rest is generated.
OrderId : IValue<Guid>gets its constructor, equality, comparison, parsing, formatting, JSON converter and type converter at compile time. - Validation in one place. Write
Createonce, and JSON, route binding, parsing andTryFromall run it. Nothing skips it by accident. - A drop-in for primitives. The JSON, the database column and the OpenAPI schema are the same as
the underlying type's. It works for strings, numbers,
Guid, dates, enums,Uri, NodaTime types and your own types. - Smart enums. One line per member gives you parsing by name, JSON as the name, an OpenAPI string
enum and a text column.
Matchbreaks the build when a member is added and not handled.
Option and Result you can't misuse
- No
.Valueto forget a check on. Adefaultvalue is a compile error, and an ignoredResultis flagged. - Pipelines, async chains, collecting many results, and
Result.Catchfor code that throws. - Neither type serializes silently to
{}. The serializer refuses them, so a missing value can't reach the wire unnoticed.
ASP.NET Core and EF Core, with nothing to register per type
- EF Core: value objects map to plain columns and keys, identity keys included, and
.Valuein a LINQ query becomes plain SQL. - ASP.NET Core: return a
Resultfrom an endpoint and its errors become problem-details responses. The OpenAPI document shows each value object as its underlying type, plus the error responses an endpoint can return. - JSON Merge Patch:
Patch<T>tells "absent", "null" and "a value" apart.
Mistakes caught at build time
- Analyzers flag default values, ignored results, bypassed validation, and generic type arguments
that break declared constraints (
[Sealed],[Distinct]and more). They also run in Razor files. - A declaration that can't be generated fails the build with its name on it, instead of compiling into an empty type.
Ready for production
- Native AOT and trimming in every run-time package, checked in CI by publishing real apps as native binaries (what it asks of you).
- Secrets stay out of logs. Error messages never quote the input, and Serilog logs a value object as it prints itself.
- Test assertions for
Option,ResultandPatch<T>, with no dependency on a test framework. - Take only what you need. The
Option/Resultpackage brings no code generator with it.
Two value objects, the second one validated:
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.
A closed set, the smart enum, is declared the same way, one line per member:
public sealed partial class OrderStatus : IClosedSet<OrderStatus>
{
public static readonly OrderStatus Open = new();
public static readonly OrderStatus Shipped = new();
public static readonly OrderStatus Cancelled = new();
}The generators add All, parsing by name, JSON as the name, an OpenAPI string enum and a text
column in EF Core, with nothing registered per set. Match takes one required delegate per member,
so a member added later fails every call until it is handled.
The base package also gives you:
Patch<T>, a field of a JSON Merge Patch: absent, null or a value.Result.Catch, which turns an exception into a result.AsText()andAsIdentifier(), which check text a person wrote and a value another system issued.[Sealed],[Concrete]and[RequireParameterlessConstructor], constraints that C# has nowhereclause for. The analyzers check them.
| Package | What it does | Metalama |
|---|---|---|
| CodoMetis.TypeKit | Option<T>, Result<T, TError> and Result<TError>, the interfaces value objects are built on, Patch<T>, and the text guards AsText() and AsIdentifier(). Brings the analyzers that check them |
no |
| CodoMetis.TypeKit.Analyzers | Build-time checks, CMTK0001 to CMTK0014. They arrive with the base package and run in Razor components too. They flag: - a default value object, Option or Result, written in your code or handed out by FirstOrDefault and its kind- a value object nothing generates - an ignored result - skipped validation - a comparison of two value objects' values - a type argument that breaks [Abstract], [Sealed], [Record], [Concrete], [Interface], [Unsealed], [ValueObject], [ClosedSet], [FlagsEnum] or [ReadOnlyStruct] on its parameter, or two that [Distinct] or [Unrelated] keep apart- a type without the constructor its host will look for - a closed-set Match bound by position, and a closed-set member written after it was named- a FaultKind or ConstructorAccessibility that names no member |
no |
| CodoMetis.TypeKit.Generators | Generates your value objects at compile time. Add it to the project that declares them | yes |
| CodoMetis.TypeKit.EntityFrameworkCore | Maps value objects to columns with nothing to register per type, and makes .Value work in LINQ |
no |
| CodoMetis.TypeKit.AspNetCore | Shows value objects in the OpenAPI document with the schema of their underlying type. Returns a Result as the response, and a fault as problem details |
no |
| CodoMetis.TypeKit.Serilog | Logs value objects as what they print, also where Serilog destructures them, so a value masked by its ToString() stays masked |
no |
| CodoMetis.TypeKit.Testing | Test assertions for Option, Result and Patch<T>. They hand back the value or the error, and fail saying what the outcome was. No test framework needed |
no |
Take only what you need. If you only want Option and Result, the base package brings no code
generator with it. You take Metalama on once, on purpose, by adding CodoMetis.TypeKit.Generators to
your domain project.
The EF Core and ASP.NET Core packages work at run time through the interfaces. So a host that maps value objects from a referenced domain assembly needs no generator itself.
Add the generators to your domain project, which brings the base package along. Add the EF Core package to your data project, if you have one.
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 oneDeclare a value object with its validation:
using 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());
}
}Then use it:
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("ops@example.com"); // a literal you vouch for; throws otherwise
string reply = created.Match(
email => $"Welcome, {email}",
fault => $"Please check the address ({fault})");
JsonSerializer.Serialize(known); // "ops@example.com"
JsonSerializer.Deserialize<Email>("\"nobody\""); // JsonException naming Email and NoAt, never the text
Email.Parse("ops@example.com", null); // IParsable, so it binds as a route or query parameterSet up EF Core and OpenAPI once, for every value object:
services.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"}The pages below are guides, one topic each, built around examples. The package READMEs above are the reference for each package: what it adds, how to set it up, and the rules it follows. Every exception message and analyzer help link points into them. Every C# block in the guides and the READMEs is compiled, and the outputs it states are tested.
| Page | Covers |
|---|---|
| Value objects | Declare one, pick a struct or a class, validate input, design its errors, pick the right factory, write your own members, generic code |
| Closed sets | Smart enums that carry data: declare one, handle every member with Match, send it as JSON, store it as a text column, read it from configuration, use one as an error type, and when an enum is still the right choice |
| Option | A value or nothing: create one, get the value out, combine options, convert at the boundary, and every public member |
| Result | A value or an error: pick a shape, chain steps, async chains, collect many results, an exception as an outcome, translate errors between layers, and every public member |
| Written text | AsText() and AsIdentifier(): pick a guard, what each cleans and rejects, required and optional fields, a value object on a guard |
| Type constraints | Tell a generic which types it accepts, beyond what where can say: [Abstract], [Sealed], [Record], [Concrete], [Interface], [Unsealed], [ValueObject], [ClosedSet], [FlagsEnum] and [ReadOnlyStruct] on a type parameter, [Distinct] and [Unrelated] on two, passing a constraint on, and [RequireParameterlessConstructor] with its code fix |
| Merge patches | Patch<T> for PATCH endpoints: tell absent, null and a value apart, apply and validate a patch, write one, the OpenAPI document, source-generated contexts |
| JSON | A value object's JSON is the underlying type's: options, rejected input, null and missing properties, stored documents, decimals, describing your own converters, Option and Result at the wire, source-generated contexts |
| EF Core | Value objects as plain columns: the model, keys, queries and the SQL they become, seed data, reading back without validation, compiled models, SQLite in tests |
| ASP.NET Core | Take value objects as endpoint parameters and return a Result: binding in minimal APIs and MVC, what a rejected value gets back, errors as problem details, and the OpenAPI document |
| Native AOT | What Native AOT asks of your app: project settings, JSON contexts, OpenAPI, EF Core's compiled model and query helpers, configuration binding, written text, Serilog, discovery without reflection, what the smoke test measures |
| Logging and secrets | Keep a secret out of logs: mask a value, what still carries it, Serilog, Microsoft.Extensions.Logging, rejections that never quote the input |
| Testing | Assertions for Option, Result and Patch<T>, Create as a table, the generated members checked against Create, EF Core on SQLite, endpoints on the test server |
| Migrating from 2.0 to 2.1 | Move a project from 2.0 to 2.1: two new warnings, value equality over a base record, preserved references, the same converters for controllers and minimal APIs, a changed OpenAPI document, names 2.1 adds |
| Migrating from 1.5 to 2.0 | Move a project from 1.5 to 2.0: dropped results as build errors, queries that now run in the database or throw, names 2.0 adds, fault responses, Native AOT |
| How it compares | When another library fits better, and feature tables against the value-object, result and smart-enum libraries, versioned and dated |
- No
.ValueonOptionorResult. You reach the content throughMatch,TryGetValueand the other members, so you handle absence and failure where you use the value. AdefaultOptionisNone. AdefaultResultis uninitialized, and every branching member throws on it rather than inventing adefault(TError).ToString()never prints the content. OptionandResultstay off the wire. System.Text.Json throwsNotSupportedExceptionfor either, in both directions, instead of writing{}that reads back asNone. Say "absent" with a nullable in a serialized shape, and convert at the boundary withToOption()andOrNull(). If you want a wire format of your own, a converter you register on the options takes precedence.- One set of rules per validated value object.
Createis the only factory you write. The generatedTryFrom,FromKnownGood, JSON converter, parsing and type converter all run it, and each reports the fault you return fromCreate: theJsonException, theFormatExceptionand theFromKnownGoodexception all name yourEmailFault.NoAt. - Only two paths skip validation, and both are named. The EF Core package reads a database
column back without validation.
StoredJsonConverterFactoryreads JSON the application stored itself. Register that factory only on the options you read your own JSON with, never on the ones that read a request. - No factory throws on input by accident. A validated value object has no
From. You create one withCreate, which returns aResult, withTryFrom, which returns anOption, or withFromKnownGood. Its name says you vouch for the value, and its exception points at your call site. - Rejections never repeat the input. A JSON or parsing rejection names the value object and the
rule, or the underlying type it could not read. It carries no inner exception that quotes the text.
FromKnownGoodnames your expression, andOptionandResultprint nothing. So a secret doesn't end up in a message or a log through this package, as long as your fault type doesn't carry it. One exception comes from System.Text.Json: a rejected dictionary key is in theJsonException'sPath, and so in MVC's 400 (Logging and secrets). - Swapping a primitive for a value object keeps the JSON, the column and the schema. Its JSON is the underlying type's
under your options, with two exceptions: a
DateTimeis written as UTC, and no converter sees number handling set by an attribute on the containing type (JSON). Its column is the underlying type's, and a key over an integer is still an identity column. Its OpenAPI schema is the one ASP.NET Core publishes for the underlying type. What changes is what the value object rejects, a JSONnullfor a struct value object included. And under a source-generated context, a value object over an enum needs its enum listed (JSON). - Failures are loud. A declaration that can't be generated fails the build and names the
declaration (CMTK1000 to CMTK1022). It never compiles into a type with nothing in it. The analyzers
make a
defaultvalue object,OptionorResultan error. - Discovery by interface. The EF Core package and the ASP.NET Core package's OpenAPI support
recognise a value object by the attribute the generators put beside
IValueObject<,>. The attribute's type arguments are constrained to that interface. Renaming a type or a namespace changes nothing, and trimming keeps the attribute. - Native AOT. The run-time packages build with the trim and AOT analyzers on. On every CI build, an app with value objects, source-generated JSON, OpenAPI and EF Core is published with Native AOT and run. Each package's README says what, if anything, Native AOT asks of you.
Good libraries exist for both halves of TypeKit. TypeKit fits when you want:
- One model from validation to response. The value objects, the outcomes, and the HTTP and
database adapters share it. The fault
Email.Createreturns is the error of aResultpipeline, and then the problem response, with no mapping between a validation library, a result library and an adapter. - Value objects that change no contract, on .NET 10 with System.Text.Json, EF Core and ASP.NET
Core. The JSON, the column and the OpenAPI schema stay the underlying type's, with nothing
registered per type, and
.Valueworks inside a LINQ query. - An OpenAPI document that describes what the wire carries. A converter you register once, such as one that writes decimals as strings, is described everywhere it applies, value objects and patches included. A document that wouldn't match what the controllers write is rejected, not served.
- One set of rules per value object. JSON, parsing, route binding,
TryFromandFromKnownGoodall run theCreateyou write. You declare a value object by implementing an interface, not with an attribute, so generic code and the EF Core and ASP.NET Core packages can use it. - An
Optionand aResultyou can't misuse. No.Value, nodefaultin your code, ignored results reported, and no silent{}on the wire. - Native AOT for your published app.
How it compares says what to choose instead when TypeKit doesn't meet your need. It also has detailed tables against Vogen, Thinktecture.Runtime.Extensions, StronglyTypedId, LanguageExt, CSharpFunctionalExtensions, ErrorOr, Ardalis.SmartEnum and Intellenum, with versions and dates.
TypeKit's value-object generation exists in this form because of Metalama:
- No attribute, nothing to register. A transitive fabric finds every type that implements a
TypeKit contract. It looks in the project that references
CodoMetis.TypeKit.Generatorsand in every project that references that one. So you declare a value object by implementing its interface. - Generated code you can read. Templates write the generated members as ordinary C#, and a build can write that code out for you (Reading the generated code).
- Loud failures. A declaration that can't be generated fails the build with its name on it (CMTK1000 to CMTK1022). It never compiles to less than it appears to.
- Analyzers in Razor. 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 the four
other packages don't reference it, so a project that only wants Option and Result never gets it.
Metalama 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.
2.1.0 is the current version. 1.0.0, released on 28 September 2026, was the first. Migrating from 2.0 to 2.1 lists what builds or runs differently since 2.0, and Migrating from 1.5 to 2.0 what changed since 1.5.
All packages share one version and follow Semantic Versioning. The versioned surface is the public API, the generated members of a value object, and the analyzer ids and severities. 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 design notes, the decision records and the roadmap are under design/. 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 says how to report a vulnerability.
- 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 doesn't mean.