Strong types for .NET 10, with one set of rules from the request to the database. Value objects and
closed sets (smart enums) are written as one line and generated at compile time. A value object's one
Create is what JSON, route binding, parsing and TryFrom apply, and the fault it returns reaches an
HTTP problem response as a typed Result, with nothing to translate between libraries on the way.
EF Core and the OpenAPI document see the type it wraps. Option and Result cannot be misused: no
.Value, no default in source, no ignored result.
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.
Beside the generated types, the base package holds:
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(), for text a person wrote and a value another system issued.[Sealed],[Concrete]and[RequireParameterlessConstructor], constraints C# has nowherefor, checked by the analyzers.
| Package | Role | Metalama |
|---|---|---|
| CodoMetis.TypeKit | Option<T>, Result<T, TError>, Result<TError>, the value-object contracts, the text guards AsText() and AsIdentifier(), and the analyzers that guard them |
no |
| CodoMetis.TypeKit.Analyzers | CMTK0001–CMTK0013: 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; no type argument that breaks what its parameter declares with [Abstract], [Sealed], [Record] or [Concrete]; no type without the constructor its host will look for; no closed-set Match bound by position; no closed-set member written after it was named. 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; a Result as the response, a fault as problem details |
no |
| CodoMetis.TypeKit.Serilog | Value objects logged 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> that hand back the value or the error, and fail saying what the outcome was. No test framework dependency |
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.
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("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 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"}The package READMEs above are the reference for each package: what it adds, its setup, and the rules it holds to, which is also where every exception message and analyzer help link lands. The pages below are the guides, one topic each, written around examples. Every C# block in them is compiled and its stated outputs are tested, as the READMEs' are.
| Page | Covers |
|---|---|
| Value objects | Declaring one, struct or class, identifiers, validated text and numbers, faults, which factory to call, your own members, generic code over value objects |
| Closed sets | Smart enums: declaring one, data on its members, Match, JSON and dictionary keys, EF Core columns, configuration, logging, what the generators refuse, when an enum is still right |
| Option | Absence as a type: creating, matching, combining, converting at the boundary, and the complete public surface |
| Result | Outcomes as a type: faults, pipelines, async chains, collecting results, an exception as an outcome, errors across layers, and the complete public surface |
| Written text | AsText() and AsIdentifier(): what each cleans and refuses, a value object over either, a required and an optional field |
| Type constraints | [Abstract], [Sealed], [Record] and [Concrete] on a type parameter, [RequireParameterlessConstructor] and its code fix |
| Merge patches | Patch<T>: absent, null or a value, applying a patch, value objects inside one, writing one, the OpenAPI document, source-generated contexts |
| JSON | What a value object writes and reads, refusals, stored documents, Option and Result at the wire, a converter of your own, source-generated contexts |
| EF Core | Columns, keys, conventions, queries and the SQL they become, seed data, reading back without validation, SQLite in tests |
| ASP.NET Core | Binding in minimal APIs and MVC, what a refused value answers, a Result as a response, fault responses a client can act on, the OpenAPI document, problem details |
| Native AOT | What is measured, the project settings, JSON contexts, OpenAPI, EF Core's compiled model, configuration binding, discovery without reflection |
| Logging and secrets | A value that must not be printed, what still carries it, Serilog, Microsoft.Extensions.Logging, refusals that never quote the input |
| Testing | Assertions for Option, Result and Patch<T>, table-driven tests of Create, EF Core on SQLite, endpoints on the test server |
| Migrating from 1.5 to 2.0 | What builds or runs differently: CMTK0003 as an error, a query's FirstOrNone in the database, names that 2.0 adds, fault responses, Native AOT |
| How it compares | What to choose instead for a need TypeKit does not meet, and the detail tables against the value-object, result and smart-enum libraries, versioned and dated |
- 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 are explicit and named: the EF Core satellite reads a database column through the materializer, andStoredJsonConverterFactoryreads JSON the application stored itself. Register that factory only on the options you read your own JSON with, never on those that read a request. - 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. The exception is System.Text.Json's own: a refused dictionary key is in theJsonException'sPath, and so in MVC's 400 (Logging and secrets). - Swapping a primitive for a value object keeps the contract. Its JSON is the wrapped type's
under your options, apart from a
DateTime, which is written as UTC, and number handling set by an attribute on the containing type, which no converter sees (JSON); 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. What changes is what the value object refuses, a JSONnullfor a struct value object included, and under a source-generated context an enum-backed value object needs its enum listed (JSON). - Loud failures. A declaration that cannot be generated is a build error naming the
declaration, CMTK1000 to CMTK1022, 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 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, 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. TypeKit fits when:
- You want the value objects, the outcomes and the HTTP and database adapters to share one model:
the fault
Email.Createreturns is the error of aResultpipeline and then the problem response, without mapping between a validation library, a result library and an adapter. - 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.
What to choose instead for the needs TypeKit does not meet, and the detail tables against Vogen, Thinktecture.Runtime.Extensions, StronglyTypedId, LanguageExt, CSharpFunctionalExtensions, ErrorOr, Ardalis.SmartEnum and Intellenum, versioned and dated, are on How it compares.
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 CMTK1022,
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 the
four 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.
2.0.0 is the current version; 1.0.0, of 28 September 2026, was the first release, and Migrating from 1.5 to 2.0 lists what builds or runs differently since 1.5. The 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 design notes, the decision records and the roadmap are under design/, 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.