Skip to content

About

Strong types for .NET 10: Option and Result that cannot be misused, and one-line value objects generated at compile time by Metalama, with analyzers, EF Core column mapping and OpenAPI schemas. Native AOT ready.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CodoMetis.TypeKit

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.

.NET NuGet Built with Metalama

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.

Packages

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.

Quick start

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
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());
    }
}
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 parameter
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"}

What it holds to

  • No .Value on Option or Result. The content is reached through Match, TryGetValue and the combinators, so absence and failure are handled where the value is used. 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.
  • Not wire types. Serializing an Option or a Result with System.Text.Json throws NotSupportedException, in both directions, instead of writing {} that reads back as None. A serialized shape says absent with a nullable, ToOption() and OrNull() 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. Create is the only factory written by hand, and the generated TryFrom, FromKnownGood, JSON converter, parsing and type converter all apply it. The fault you return from Create is what each of them reports: the JsonException, the FormatException and the FromKnownGood exception all name your EmailFault.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 are Create, which returns a Result, TryFrom, which returns an Option, and FromKnownGood, 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; FromKnownGood names the caller's expression, and Option and Result print 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 default of a value object, an Option or a Result an 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 by GetInterfaces(), 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.

How it compares

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.

Choose TypeKit when

  • 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 .Value works inside a LINQ query.
  • You want one rule set per value object: the Create you write is what JSON, parsing, route binding, TryFrom and FromKnownGood apply, 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 Option and a Result that cannot be misused: no .Value, no default in source, an ignored result reported, no silent {} on the wire.
  • You publish with Native AOT.

Choose something else when

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

Value objects in detail

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

Option and Result in detail

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

Built with Metalama

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.

Status

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

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

License

MIT

About

Strong types for .NET 10: Option and Result that cannot be misused, and one-line value objects generated at compile time by Metalama, with analyzers, EF Core column mapping and OpenAPI schemas. Native AOT ready.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages