Strong types for .NET 10, with one set of rules from the request to the database.
TypeKit is built around three things:
- Value objects, generated for you. You declare an
OrderIdin one line, and the rest is written at compile time. Optionfor a value that may be missing.Resultfor an operation that can fail.
The value objects work with System.Text.Json, EF Core and ASP.NET Core, with nothing to register per type.
An order id is one line:
public readonly partial record struct OrderId : IValue<Guid>;When you build, the generators fill in everything an OrderId needs to stand in for a Guid:
- Make one and read it back with
FromandValue. Two ids are equal when theirGuids are. - Sort and compare them through
IComparableand the<,<=,>and>=operators. - Bind one from a route or a query string, because
Parse,TryParseandToStringare there, span forms included. - Send it and store it through its JSON converter and its type converter.
The JSON, the database column and the OpenAPI schema are the same as the underlying type's. So you
can change a Guid property to an OrderId without changing your API or your tables.
When a value has rules, you write them once, in Create:
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);
}JSON, route binding, parsing and the generated TryFrom all run your Create. So an Email that
breaks your rules can't get in through any of them. A value object can wrap a string, a number, a
Guid, a date, an enum, a Uri, a NodaTime type or a type of your own.
Then you create one in the way that fits the input:
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
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 parameterCreatewhen you want to know which rule failed.TryFromwhen yes or no is enough.FromKnownGoodfor a value you vouch for, such as a literal. It throws if you were wrong.
A struct always has a public parameterless constructor, and C# won't let you take it away. So the
analyzers make new OrderId() and default(OrderId) build errors (CMTK0001). An array created with
a length, whose slots all start out as default, gets a warning (CMTK0005).
What the generators write for OrderId, trimmed
public readonly partial record struct OrderId : IValue<Guid>, IValueObject<OrderId, Guid>, IParsable<OrderId>, IComparable<OrderId>
{
private readonly Guid _value;
private OrderId(Guid value)
{
_value = value;
}
public Guid Value
{
get { return _value; }
}
public static OrderId From(Guid value)
{
return new OrderId(value);
}
public int CompareTo(OrderId other)
{
return Comparer<Guid>.Default.Compare(Value, other.Value);
}
public override readonly string ToString()
{
return ToString(null, CultureInfo.InvariantCulture);
}
// Also: Parse and TryParse, the formatting interfaces, the comparison operators,
// a JSON converter and a type converter, each nested in the type.
}The Generators README lists every member, and says how to read the full generated code of your own types.
An Option<T> holds a value or nothing. Use it instead of null where a value may be missing:
Option<Customer> found = customers.FirstOrNone(c => c.Email == email);
string greeting = found.Match(
customer => $"Welcome back, {customer.Email}",
() => "Please sign up first");What it gives you:
- No
.Valueto forget a check on. You reach the value throughMatch,TryGetValueand the other members, and each one asks what should happen when there is nothing. - No null in disguise.
Option.Some(null)throws, and writingdefault(Option<T>)fails the build (CMTK0001).
A Result<T, TError> holds a value, or an error of a type you choose. Use it for failures you expect,
such as invalid input, instead of an exception:
Result<Customer, SignUpFault> signedUp = Email.Create(input)
.MapError(_ => SignUpFault.InvalidEmail)
.Ensure(email => !customers.Exists(c => c.Email == email), SignUpFault.EmailTaken)
.Map(email => new Customer(CustomerId.New(), email));
string reply = signedUp.Match(
customer => $"Welcome, {customer.Email}",
fault => $"Sign-up failed: {fault}");Each step runs only while everything before it succeeded. The first error skips the rest and ends up
in Match.
What it gives you:
- The error is part of the signature. Whoever calls your method sees what can go wrong, and has to handle it to get the value.
- A result you ignore is reported (CMTK0003).
- Errors become HTTP responses. Return
ToHttpResult()from an endpoint, and an error becomes a problem-details response. - Exceptions become results where you want them to, with
Result.Catch.
Option and Result never end up on the wire by accident: System.Text.Json rejects both, rather
than writing {}.
A closed set is an enum whose members can carry data. You declare it 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();
}You get All, parsing by name, JSON as the name, an OpenAPI string enum and a text column in EF
Core. Match takes one required delegate per member, so a member added later fails every call until
you handle it (Closed sets).
You set them up 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"}Value objects map to plain columns and keys, identity keys included, and .Value in a LINQ query
becomes plain SQL (EF Core). The OpenAPI document shows each value object as its
underlying type, plus the error responses an endpoint can return (ASP.NET Core).
- JSON Merge Patch.
Patch<T>tells "absent", "null" and "a value" apart (Merge patches). - Checks for written text.
AsText()andAsIdentifier()check text a person typed and an id another system issued (Written text). - Mistakes caught at build time. Analyzers flag default values, ignored results, skipped
validation, and type arguments that break constraints C# can't express (
[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. - Native AOT and trimming in every run-time package, checked in CI by publishing real apps as native binaries (Native AOT).
- Secrets stay out of logs. Error messages never quote the input, and Serilog logs a value object as it prints itself (Logging and secrets).
- Test assertions for
Option,ResultandPatch<T>, with no dependency on a test framework (Testing).
The pages below are guides, one topic each, built around examples. The package READMEs, listed under Packages, 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.
You can also read all of it as one website, with a menu, search and the API of every run-time package: caffeinatedcoder.github.io/CodoMetis.TypeKit. It shows the latest release.
| Page | Covers |
|---|---|
| Walkthrough | One orders service from request to response: value objects bound, validated, stored, queried, patched, logged and described, and what changes at each boundary |
| 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 |
One package uses Metalama, a framework that generates C# code at compile time from templates written in C#. TypeKit's generators are built on it. The last column shows which package brings it.
| 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:
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 oneThe 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.
- 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, SuperStrong.Types, 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.3.1 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.
TypeKit's value objects owe a great deal to Vogen by Steve Dunn. Much of how they are generated started as an idea from Vogen. Thank you, Steve.
The generators themselves run on Metalama, and Built with Metalama says what that makes possible.