CodoMetis.TypeKit.Generators
2.1.0
Prefix Reserved
dotnet add package CodoMetis.TypeKit.Generators --version 2.1.0
NuGet\Install-Package CodoMetis.TypeKit.Generators -Version 2.1.0
<PackageReference Include="CodoMetis.TypeKit.Generators" Version="2.1.0" />
<PackageVersion Include="CodoMetis.TypeKit.Generators" Version="2.1.0" />
<PackageReference Include="CodoMetis.TypeKit.Generators" />
paket add CodoMetis.TypeKit.Generators --version 2.1.0
#r "nuget: CodoMetis.TypeKit.Generators, 2.1.0"
#:package CodoMetis.TypeKit.Generators@2.1.0
#addin nuget:?package=CodoMetis.TypeKit.Generators&version=2.1.0
#tool nuget:?package=CodoMetis.TypeKit.Generators&version=2.1.0
CodoMetis.TypeKit.Generators
Declare a value object in one line, and get a complete type at compile time. This package generates
value objects and closed sets (smart enums) for
CodoMetis.TypeKit, built on
Metalama. Each one comes with its JSON converter, parsing,
formatting, comparison, type converter and query helpers, and every one of them runs the one
Create you wrote.
dotnet add package CodoMetis.TypeKit.Generators
Reference it in the project that declares your value objects. Projects that reference that one,
directly or further down, get the generators too, without referencing the package themselves.
Metalama.Framework comes along with it, because the fabric that applies the generators runs in
every one of those projects. You need no Metalama license key, there or here: the generators build on
Metalama's Open Source edition.
This page is the reference for the package. For a guide to designing value objects, read Value objects. To see how TypeKit compares with Vogen, Thinktecture.Runtime.Extensions and StronglyTypedId, and when one of them is the better choice, read How it compares.
Built with Metalama
This package exists in this form because of Metalama. A transitive fabric finds every type that implements a TypeKit interface, in the project that references this package and in every project that references that one. So you declare a value object by its interface and nothing else.
Templates write the generated members as ordinary C#, which you can read (see "Reading the generated code"). A declaration that can't be generated fails the build with its name on it (see "Build errors"), instead of compiling to less than it appears to. Thanks to the Metalama team.
Declare a value object
using CodoMetis.TypeKit;
using CodoMetis.TypeKit.ValueObjects;
public readonly partial record struct OrderId : IValue<Guid>;
public readonly partial record struct Quantity : IValue<int>;
public enum EmailFault { Blank, NoAt, TooLong }
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);
var trimmed = value.Trim();
if (!trimmed.Contains('@')) return Result.Error(EmailFault.NoAt);
if (trimmed.Length > 254) return Result.Error(EmailFault.TooLong);
return new Email(trimmed); // the private constructor is generated
}
}
A value object is a readonly partial record struct, or a sealed partial record class when it has
to be a reference type. There are two interfaces:
IValue<T>wraps anyT.IValidatedValue<TSelf, T, TFault>addsCreate, the one method you write. Every way a value gets created runs it.
A validated value object has no From. You create one with Create, TryFrom or FromKnownGood,
so nothing harmless-looking throws on input. The one factory that throws says in its name that you
vouch for the value, and its exception names the call site, never the value.
The error Create returns (TypeKit calls it a fault) is what every rejection reports: in that
exception, in the JSON converter's JsonException, and in Parse's FormatException.
You can declare a value object inside another type. Order.Id is generated like any other. Its
companion class is OrderIdExtensions, named after the whole nesting chain, so Order.Id and
Customer.Id don't collide.
var id = OrderId.New(); // a version 7 Guid
var quantity = Quantity.From(3);
Result<Email, EmailFault> created = Email.Create(input); // says what to fix
Option<Email> maybe = Email.TryFrom(input); // is it valid?
Email known = Email.FromKnownGood("ops@example.com"); // yours to guarantee; throws otherwise
int three = quantity.Value; // 3
bool same = quantity == Quantity.From(3); // true, value equality
string text = quantity.ToString(); // "3"
What you can wrap
Any non-generic class, struct or enum, with the exceptions listed under CMTK1005. The generated members follow what the underlying type can do:
- Parsing where it can be parsed.
- Formatting where it formats.
- Comparison where it's comparable.
Numbers, bool, char, string, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly and
enums have all of it. A Uri has no ordering. Neither do NodaTime's OffsetDateTime,
ZonedDateTime, Period and Interval, and Interval also has no type converter to parse through.
An enum parses as Enum.TryParse does, case-sensitively: a name, a comma-separated list of names, or
a number, which doesn't have to name a member.
A generic underlying type, a tuple included, fails the build (CMTK1005). A List<T> or an
ImmutableArray<T> compares by reference, so the value object wouldn't have value equality. Wrap a
type of your own that does.
What is generated
| Member | For | Notes |
|---|---|---|
Value, a private constructor, value equality |
every value object | The record's equality, over the underlying value. Declare no constructor of your own, not even a parameter list (CMTK1009). |
From(T) |
IValue<T> |
Rejects null for a reference type. |
TryFrom(T), FromKnownGood(T) |
IValidatedValue |
Both built from your Create. FromKnownGood throws with the caller's expression in the message, never the value. Declare either one yourself and it isn't generated (see "Write your own members" below). |
Revalidate() |
IValidatedValue |
Runs Create on the value an instance holds. It answers: which values read back without validation (a column, a stored document) would today's rules reject? Returns what Create returns, normalisation included. |
[JsonConverter] with a nested converter |
every value object | Reads and writes the underlying value exactly as the serializer does, also as a dictionary key. A struct value object rejects a JSON null (see JSON below). |
IParsable, ISpanParsable, IUtf8SpanParsable |
when the underlying type can be parsed | A Uri parses relative or absolute, as JSON reads it. A NodaTime type parses through its type converter. |
IFormattable, ISpanFormattable, IUtf8SpanFormattable, ToString() |
every value object, unless you write ToString() |
Invariant. A ToString() you write is kept, and then none of the formatting interfaces is generated, so interpolation and string.Format use yours too. |
IEqualityOperators<TSelf, TSelf, bool> |
every value object | The generic-math form of ==/!=, so a value object satisfies that constraint. |
IComparable<TSelf>, IComparable, IComparisonOperators<TSelf, TSelf, bool>, < > <= >= |
when the underlying type is comparable | Strings compare ordinally, so ordering agrees with equality. |
MinValue, MaxValue, IMinMaxValue<TSelf> |
IValue<T> over a type whose own static MinValue/MaxValue are of that type |
Never for a validated value object. |
[TypeConverter] with a nested converter |
when parsing is generated | MVC model binding and the reflection-based configuration binder use it. The configuration binding source generator, which PublishAot and PublishTrimmed turn on, doesn't bind value objects and leaves them default. Bind the underlying type there. |
IConvertible |
when the underlying type is convertible | Explicit, so Convert.ToInt64(quantity) and Convert.ChangeType(quantity, typeof(Quantity)) work. |
{TSelf}Extensions.GetValue() and ValueOrNull() |
every value object an extension class can reach (not a private or protected nested one) |
For EF Core queries, where they translate to the column. |
IValueObjectMaterializer<TSelf, T> |
every value object | Explicit, so it's invisible on the type. Rebuilds an instance without validation, for the EF Core package. |
Every generated way of creating a validated value object goes through Create. Two paths skip it,
both for values your application wrote itself:
- The explicit materializer, which the EF Core package reads a column through.
StoredJsonConverterFactory, registered only on the options that read your application's own JSON.
Inside the type, your own members can reach the private constructor, which applies no rules, because
Create needs it. Call it from Create only. For another way of creating a value, write a static
method that calls From or Create.
Good to know
default(Email)isn't a way to create one. The analyzer rejects it (CMTK0001). But an array slot, or a JSON document without the property, can still produce it (CMTK0005, and "A missing property" below). ItsValueis the underlying type'sdefault:Guid.Emptyfor anOrderId, andnullfor anEmaildespite itsstringtype.
Write your own members
A value object may declare its own TryFrom, FromKnownGood, Revalidate, CompareTo(TSelf) and
ToString(). Each one is kept, and whatever depends on it is built from it.
Writing any other generated member or attribute by hand fails the build, naming it (CMTK1011). That covers:
- A plain value object's members:
From,Value,ParseandTryParse,MinValue/MaxValue. - The attributes:
[JsonConverter]and[TypeConverter]. - The interfaces the generators implement, and
IConvertiblewhere they generate it. - An explicit implementation of a member of an interface they implement, such as
IParsable<TSelf>.ParseorIFormattable.ToString. Every caller through the interface would reach it rather than the generated member, and a genericT.Parsewould skipCreate.
Declare Create once, public static, and never as an explicit interface implementation. The
generated code can't call an explicit one. And beside a public one, generic code calling T.Create
would reach the explicit one's rules, not the ones every generated way of creating a value runs.
To change what a value object prints, declare ToString(). A record's
PrintMembers(StringBuilder) without it fails the build (CMTK1011), since the generated ToString()
prints the underlying value and never calls it.
Base records
What a base record declares counts as the value object's own:
- A
sealedToString()on the base is kept, since C# keeps it in every derived record. - A
ToString()that isn't sealed fails the build (CMTK1011), because the generatedToString()would replace it. DeclareToString()in the value object, or seal the base's. - Its
PrintMembers, its explicit implementations of the interfaces the generators implement, and anIConvertibleon it are rejected as they are on the value object.
Equality
Equality is the underlying value's, like the ordering, the JSON and the column. So a hand-written
Equals(TSelf), GetHashCode(), IEquatable<TSelf>.Equals, or operator == or != between two of
the type, fails the build (CMTK1011). It would make values equal that sort apart.
To make values equal that differ only in form, such as "abc" and "ABC", normalise them in Create.
Equal values then hold the same underlying value. A plain value object becomes an IValidatedValue
for that.
Over a base record, the generated Equals(TSelf) and GetHashCode() compare the underlying value
alone, and never call the base's. So a hand-written Equals(TBase), GetHashCode() or
IEquatable<TBase>.Equals on a base record isn't used, and a warning names it (CMTK1023). The value
object implements each base's IEquatable<TBase> again (IReimplementedEquatable<TBase>), so a
collection of the base compares it the way the value object does.
Obsolete value objects
An [Obsolete] on a value object, or on a type it's nested in, is copied to its
{TSelf}Extensions class, which names the value object outside it. Without the copy, with
error: true, that use failed the build.
One value, nothing else
A value object holds its underlying value and nothing else. Its JSON, parsing, type converter and column carry that value alone, while a record's equality compares every field. So any other instance state would be lost on a round trip, or make equal values unequal.
An instance field, an auto-property, a required member or a field-like event fails the build
(CMTK1012), whether declared or inherited from a base record. A property computed from Value
without a backing field is fine, and so is anything static or const.
A base record from another assembly is seen without its private fields, and a writable property of it counts as state. What a private field of it holds takes no part in the value object's equality, and is lost on a round trip like anything else beside the value.
A value that must not be printed
public sealed partial record ApiKey : IValue<string>
{
public override string ToString() => "ApiKey(***)";
}
For a value object over a secret, declare ToString(). Then no formatting interface is generated, so
these all print what your ToString() says: interpolation, string.Format, Convert.ToString, a
record that holds it, and every Microsoft.Extensions.Logging path, [LogProperties] included.
Good to know
- Some things still carry the value, because they're conversions and not display: its JSON, the
type converter and the database column. The debugger too, which shows
ToString()but expandsValue. - Serilog reads the public
Valuewhere it destructures ({@Key}, or a record holding one), pastToString(). CodoMetis.TypeKit.Serilog logs every value object as what it prints instead. - Microsoft.Extensions.Compliance: classify the logging method's parameter. A
DataClassificationAttributeon the value object's type makes the logging generator report LOGGEN035 and generate nothing for the method.
Closed sets
An enum names its members and nothing else:
- A
switchover one is never proven complete. C# only warns about a missing discard. - A member carries no data.
- The names are integers on the wire, unless a converter says otherwise.
A closed set fixes all three. It's a sealed class whose every instance is a named member, declared as
a public static readonly field. Where the members carry data, it's constant data passed through a
private constructor:
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();
}
public sealed partial class Tier : IClosedSet<Tier>
{
public static readonly Tier Basic = new(1, null);
public static readonly Tier Premium = new(2, "Priority support.");
private Tier(int rank, string? perk) { Rank = rank; Perk = perk; }
public int Rank { get; }
public string? Perk { get; }
}
var label = status.Match(open: () => "Open", shipped: () => "On its way", cancelled: () => "Cancelled"); // every member, or it does not compile
var names = OrderStatus.All.Select(s => s.Name); // Open Shipped Cancelled
var same = OrderStatus.Parse("Shipped") == OrderStatus.Shipped; // True, members are singletons
var json = JsonSerializer.Serialize(Tier.Premium); // "Premium"
The generators add:
Name, from the field's name, andQualifiedName(OrderStatus.Open).All, in declaration order and read-only.Match, with one required delegate per member, named after it.TryParseandParseby name, each ordinal, or ignoring case when you ask.ToString().- A JSON converter that writes the name and reads the member itself back.
- A private parameterless constructor, where the type declares none.
Members are singletons, so equality is reference equality. A member added later fails every Match
until it's handled.
Matching, parsing and binding
Matcharguments must be named (CMTK0012). Every parameter ofMatchhas the same type, so an argument by position would follow the declaration order, and swap branches when two members are reordered. The analyzer's code fix names them.Matchchecks every delegate for null before it picks one.- Unknown names are rejected.
Parseand the JSON converter throw for an unknown name, with an exception naming the set, never the input. A set is a dictionary key in JSON by its name too. - Casing.
Parse(name, ignoreCase: true)andTryParse(name, ignoreCase: true, out member)accept any casing. JSON, the type converter,IParsableand a bound route or query parameter stay ordinal, since what's stored or sent is the name as it is. For a header or a query string a client typed in any casing, bind it as astringand parse it withignoreCase: true. IParsable<TSelf>is implemented explicitly, for generic code that parses throughT.Parse.- A type converter reads and writes the name. So the configuration binder, MVC, and anything else
that reads a string through
TypeDescriptorcan bind a set. The binder's source generator reads no type converter (dotnet/runtime#83599). Under it, as under Native AOT, bind the name as astringandParseit. - Nothing to register per set. The EF Core package maps a closed set to a text column of the member's name. The ASP.NET Core package describes it as a string enum of the names.
- Error sets. A set whose members are errors declares
IFaultSet<TSelf>, aFaultKindand an optional description per member. The ASP.NET Core package answers it by kind.
Rules for a closed set
- Each member is initialized with
newwhere it's declared (CMTK1019), and never written again (CMTK0013, the analyzer's). A member assigned in the static constructor would hold an instance named and listed as another. - The constructors are private (CMTK1016).
- A record fails the build (CMTK1020).
- Member names. A member can't take the name of a generated member, or differ from another only
in case (
Openandopen), sinceTryParse(name, ignoreCase: true)couldn't tell them apart (CMTK1021). - A
ToString()you write is kept, and the name is still what JSON, parsing and the column use. - An overload of a generated method with another signature, such as
Parse(int rank), is kept too. Any other generated member written by hand fails the build (CMTK1011). - An instance beside the members has no name. If the set's own code creates one, reading its name throws rather than writing a null.
- A JSON
nullreads asnull, as for any class, unless the serializer'sRespectNullableAnnotationsrejects it.
JSON
JsonSerializer.Serialize(new Order(OrderId.New(), Quantity.From(3)));
// {"Id":"0199a3f4-1c00-7000-8000-000000000001","Quantity":3}
JsonSerializer.Deserialize<Email>("\"not an address\""); // JsonException naming Email and NoAt, not the text
JsonSerializer.Deserialize<Dictionary<OrderId, int>>(json); // value objects work as dictionary keys
The JSON is the underlying value's, so an OrderId is a string and a Quantity a number.
The bytes are the serializer's own for the underlying type under the same options, both as a value
and as a dictionary key. So everything you set for the underlying type applies: a converter your
options register for it, number handling, an enum converter, and DictionaryKeyPolicy. Replacing a
Guid property with an OrderId changes no JSON. A test compares the two for every family of
underlying types, under several sets of options.
Numbers and dates are read as strictly as the serializer reads the underlying type. Input it can't
read throws a JsonException naming the value object and the underlying type ("Quantity could not
read the JSON value as Int32."), with no inner exception that could carry the text. ASP.NET Core
answers it with 400.
NodaTime types use NodaTime's converters when NodaTime.Serialization.SystemTextJson is referenced
and the options have none of their own. For JSON your application stored itself, see
StoredJsonConverterFactory in the base package.
Good to know
- A
DateTimeis written as UTC. This is the converter's one addition. ALocalvalue is converted, and anUnspecifiedone is taken as UTC with its digits kept. [JsonNumberHandling]on the containing type doesn't reach a value object. The attribute reaches the number properties the serializer writes itself, and a value object's converter never sees it. Under[JsonNumberHandling(WriteAsString | AllowReadingFromString)],record Dto(int Count)writes{"Count":"5"}and reads"5". Butrecord Dto(Quantity Count)writes{"Count":5}and rejects"5"with aJsonException. On the property itself, the serializer rejects the attribute for any type but a number (InvalidOperationException). Set the number handling on the options, or on a source-generated context's[JsonSourceGenerationOptions]. Both reach the value object.
A JSON null is rejected for a struct value object. A sealed partial record reads it as null,
as any class does, since the serializer calls no converter for it. RespectNullableAnnotations = true
on the options makes it reject null for a property that isn't declared nullable.
Preserved references (ReferenceHandler.Preserve) throw a NotSupportedException where the
serializer could write $id or $ref inside the underlying value. That's a class it walks as an
object, an object the serializer writes itself, or a struct, nullable or not, holding one in a
property that isn't ignored. The serializer numbers references per call, and the converter reaches
the underlying value through a call of its own, so its numbers wouldn't match the document's.
Register a JsonConverter for the underlying type, which writes no $id for it or for the value
object, or serialize without preserving references. These are unaffected: a value the serializer writes with a value converter (a number, a
string, a Guid, a Uri), anything a converter of your own writes, on the options or on one
property, a struct of such values, and ReferenceHandler.IgnoreCycles.
A missing property isn't rejected either. The serializer calls no converter for a property the
document lacks, and leaves it default. Mark the property required, or set
RespectRequiredConstructorParameters = true for a record's constructor parameters.
With a source-generated JsonSerializerContext (and so under Native AOT), list the value
objects, not the types they wrap. The context sees a value object's converter and never what it
wraps. So the converter builds that contract itself, as the serializer would: a converter on your
options first, then the underlying type's [JsonConverter], then the serializer's built-in
converter.
An enum is the exception. A context's UseStringEnumConverter doesn't reach an enum the context
doesn't list. So a value object over an enum is written as a number, unless you list the enum
([JsonSerializable(typeof(DayOfWeek))]) or name its converter
([JsonSourceGenerationOptions(Converters = [typeof(JsonStringEnumConverter<DayOfWeek>)])]).
Everything generated is trim- and AOT-safe, and the consumer smoke test publishes it with Native AOT.
Parsing and formatting
Quantity.Parse("3", null); // Quantity 3
Quantity.TryParse("x", null, out _); // false
Email.Parse("nobody", null); // FormatException naming Email and NoAt
var de = CultureInfo.GetCultureInfo("de-DE");
var text = Amount.From(1.5m).ToString(); // "1.5", whatever the current culture
var german = Amount.From(1.5m).ToString("N2", de); // "1,50"
var shown = $"{Amount.From(1.5m)}"; // "1.5"
Text is the same on every machine. A null IFormatProvider means the invariant culture in every
generated Parse, TryParse, ToString and TryFormat, so the text doesn't depend on the culture
the process runs in. ToString() is the underlying type's own default text under the invariant
culture, and the type converter and $"{x}" write the same.
That text reads back as the same value wherever the underlying type's own does: numbers (float,
double and Half included), BigInteger, Guid, DateOnly, TimeSpan, Version, Uri, enums
and strings.
Good to know
TimeOnly,DateTimeandDateTimeOffsetlose detail as text. Their default text drops what the invariant culture's short patterns drop.TimeOnly(3, 4, 5, 123)prints03:04, aDateTimeloses its fraction of a second and itsKind, and aDateTimeOffsetits fraction. Format those with"O"(x.ToString("O", null)), which aTimeOnlyand aDateTimeOffsetread back exactly. Or keep them in their JSON form, which aDateTimereads back as the UTC instant it wrote.- A provider you pass is used as given where the underlying type's parsing and formatting take
one. A
Uri, a NodaTime type, and a type parsed through a constructor or a provider-lessParseignore it. - A default instance formats as
""over a reference type, as it always did over a string. CMTK0001 keeps such an instance out of your own code, but EF Core, MVC and serializers can make one.
When text is rejected
Text the underlying type can't read throws a FormatException naming the value object and the
underlying type ("Quantity could not read the input as Int32."), an overflow included. It has no inner
exception, because the underlying type's own message quotes the input. A rejection by Create names
the error instead.
The one place the input still appears is System.Text.Json's own: a rejected dictionary key ends the
JsonException's Path.
TryParse answers false for both. It lets anything else through, as Parse does: an exception
Create throws is a bug you should see, not text to reject.
Comparison
CompareTo and the operators follow the underlying type's ordering, ordinal for a string.
To order differently, declare CompareTo(TSelf) yourself. It's kept, and the object overload, the
operators and the interfaces are built from it. Any other hand-written comparison member fails the
build (CMTK1008), an explicit implementation of IComparable<TSelf>, IComparable or
IComparisonOperators included. So in memory, every comparison agrees with your CompareTo.
A database can't run your CompareTo, because it orders the column by the underlying value. So the
type carries [HandWrittenOrdering], and an EF Core query that compares or orders it, or takes its
Min or Max, is rejected, naming the value object, rather than answered in the column's order
(CodoMetis.TypeKit.EntityFrameworkCore).
Compare or order by .Value where the underlying value's order is what you mean.
Build errors
A declaration that can't be generated fails the build, so no type is left half-generated. Every error ends with a link to this table. CMTK1023 is the one warning:
| Id | Meaning |
|---|---|
| CMTK1000 | Not declared partial. |
| CMTK1001 | Not a record. |
| CMTK1002 | A struct not declared readonly. |
| CMTK1003 | More than one marker, such as IValue<int>, IValue<string>. |
| CMTK1004 | An IValidatedValue<TSelf, ...> whose TSelf is another type. |
| CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), file-local or nested in a file-local type, named Value or as another member the generators add (From, TryFrom, FromKnownGood, Revalidate, Parse, TryParse, TryFormat, MinValue, MaxValue, CompareTo), derived from another value object, or wrapping object, System.Enum, an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. |
| CMTK1006 | A record class not declared sealed. |
| CMTK1007 | The {TSelf}Extensions companion's name is taken by a declared type or by another value object's companion. |
| CMTK1008 | A hand-written comparison operator, object CompareTo or explicit implementation of a comparison interface beside the generated ones. |
| CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as OrderId(Guid Value). The constructor and Value are generated. A static constructor is fine. |
| CMTK1010 | Declared in more than one partial part. Metalama 2026.1 writes the generated ToString() into every part, which doesn't compile. A part a source generator adds, such as a [GeneratedRegex] method's, doesn't count. |
| CMTK1011 | A hand-written member or attribute the generators add (see "Write your own members", and for a closed set every generated member but ToString(), by signature, IParsable<TSelf>, [JsonConverter] and [TypeConverter]), an explicit implementation of an interface they implement, a hand-written equality (an operator == or != between two of the type included, which C# also reports as CS0111), a PrintMembers without ToString(), or Create implemented explicitly. |
| CMTK1012 | Instance state besides the underlying value: a field, an auto-property, a required member or a field-like event, declared or inherited. Compute it from Value, make it static, or wrap a type that holds all of it. |
| CMTK1013 | A closed set not declared partial. |
| CMTK1014 | A closed set not declared sealed. A set with subtypes isn't closed. |
| CMTK1015 | A closed set with no member. A member is a public static readonly field of the set's own type. |
| CMTK1016 | A closed set with a constructor that isn't private, so an instance beside its members could be made. |
| CMTK1017 | A closed set with a static member of its own type that isn't a public readonly field: a property, a field that isn't readonly, or a field that isn't public. Each of those is an instance that is no member. |
| CMTK1018 | A closed set that is generic or nested in a generic type. |
| CMTK1019 | A closed set's member not initialized with new where it's declared, and with nothing else. = Open would give one instance two names. A member set later, by a call, or by an expression that only starts with new, may be another member or null. |
| CMTK1020 | A closed set declared as a record. with copies a member into an instance that is none, and a record compares by value where members are singletons. |
| CMTK1021 | A closed set's member named as a member the generators add (Name, QualifiedName, All, Match, Parse, TryParse, ToString, the JSON or type converter), a set itself named as one of them (a member can't have the name of its type), or a member whose name differs from another member's only in case. Those collide in TryParse(name, ignoreCase: true), and in Match's parameters where the first letter is the difference. Also two members whose Match parameters would have one name, which lower-casing the first letter gives a member starting with the Kelvin sign (U+212A) and one starting with K. |
| CMTK1022 | An abstract type with a value-object marker. It would get no member, and a value held as it, of a type derived from it, would have no Value and be written as {}. Declare the marker on the sealed type. An abstract closed set is CMTK1014. |
| CMTK1023 | A warning, and the value object is generated. A base record declares Equals(TBase), GetHashCode() or IEquatable<TBase>.Equals, which the generated equality, the underlying value's, doesn't call. Remove them from the base, or derive from a record without them. |
The analyzers that come with the base package add CMTK0001 to CMTK0014. See CodoMetis.TypeKit.Analyzers.
Reading the generated code
Set <MetalamaEmitCompilerTransformedFiles>true</MetalamaEmitCompilerTransformedFiles> in the project,
then open obj/Debug/net10.0/metalama/. Every generated member is there as ordinary C#.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CodoMetis.TypeKit (>= 2.1.0)
- Metalama.Framework (>= 2026.1.30)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.