Skip to content

About

Strong types for .NET 10: value objects and smart enums generated from one line, and an Option and Result that cannot be misused. One validation rule and one typed fault, carried through JSON, route binding, EF Core, OpenAPI and HTTP problem details.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CodoMetis.TypeKit

Strong types for .NET 10, with one set of rules from the request to the database.

.NET NuGet Built with Metalama Native AOT: every run-time package

What you get

TypeKit is built around three things:

  • Value objects, generated for you. You declare an OrderId in one line, and the rest is written at compile time.
  • Option for a value that may be missing.
  • Result for 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.

Value objects

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 From and Value. Two ids are equal when their Guids are.
  • Sort and compare them through IComparable and the <, <=, > and >= operators.
  • Bind one from a route or a query string, because Parse, TryParse and ToString are 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 parameter
  • Create when you want to know which rule failed.
  • TryFrom when yes or no is enough.
  • FromKnownGood for 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.

Option

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 .Value to forget a check on. You reach the value through Match, TryGetValue and the other members, and each one asks what should happen when there is nothing.
  • No null in disguise. Option.Some(null) throws, and writing default(Option<T>) fails the build (CMTK0001).

Result

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 {}.

Smart enums

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).

EF Core and ASP.NET Core

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).

And also

  • JSON Merge Patch. Patch<T> tells "absent", "null" and "a value" apart (Merge patches).
  • Checks for written text. AsText() and AsIdentifier() 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, Result and Patch<T>, with no dependency on a test framework (Testing).

Documentation

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

Packages

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 one

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.

What TypeKit guarantees

  • No .Value on Option or Result. You reach the content through Match, TryGetValue and the other members, so you handle absence and failure where you use the value. A default Option is None. A default Result is uninitialized, and every branching member throws on it rather than inventing a default(TError). ToString() never prints the content.
  • Option and Result stay off the wire. System.Text.Json throws NotSupportedException for either, in both directions, instead of writing {} that reads back as None. Say "absent" with a nullable in a serialized shape, and convert at the boundary with ToOption() and OrNull(). 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. Create is the only factory you write. The generated TryFrom, FromKnownGood, JSON converter, parsing and type converter all run it, and each reports the fault you return from Create: the JsonException, the FormatException and the FromKnownGood exception all name your EmailFault.NoAt.
  • Only two paths skip validation, and both are named. The EF Core package reads a database column back without validation. StoredJsonConverterFactory reads 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 with Create, which returns a Result, with TryFrom, which returns an Option, or with FromKnownGood. 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. FromKnownGood names your expression, and Option and Result print 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 the JsonException's Path, 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 DateTime is 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 JSON null for 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 default value object, Option or Result an 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.

How it compares

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.Create returns is the error of a Result pipeline, 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 .Value works 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, TryFrom and FromKnownGood all run the Create you 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 Option and a Result you can't misuse. No .Value, no default in 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.

Built with Metalama

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.Generators and 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.

Status

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/.

Building

dotnet build CodoMetis.TypeKit.slnx
dotnet test --solution CodoMetis.TypeKit.slnx

The SDK is pinned in global.json. The PostgreSQL round-trip tests start a container, so they need Docker. Everything else runs without it.

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.

Acknowledgements

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.

License

MIT

About

Strong types for .NET 10: value objects and smart enums generated from one line, and an Option and Result that cannot be misused. One validation rule and one typed fault, carried through JSON, route binding, EF Core, OpenAPI and HTTP problem details.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages