Egil.StronglyTypedPrimitives
2.0.56
dotnet add package Egil.StronglyTypedPrimitives --version 2.0.56
NuGet\Install-Package Egil.StronglyTypedPrimitives -Version 2.0.56
<PackageReference Include="Egil.StronglyTypedPrimitives" Version="2.0.56" />
<PackageVersion Include="Egil.StronglyTypedPrimitives" Version="2.0.56" />
<PackageReference Include="Egil.StronglyTypedPrimitives" />
paket add Egil.StronglyTypedPrimitives --version 2.0.56
#r "nuget: Egil.StronglyTypedPrimitives, 2.0.56"
#:package Egil.StronglyTypedPrimitives@2.0.56
#addin nuget:?package=Egil.StronglyTypedPrimitives&version=2.0.56
#tool nuget:?package=Egil.StronglyTypedPrimitives&version=2.0.56
Strongly Typed Primitives
A source generator for creating strongly-typed primitive types that makes it easy to avoid the primitive obsession anti pattern.
Features
Ensure that a strongly-typed primitive is always valid (per
IsValueValidmethod), or equal toEmpty.Any generated method or property can be overridden by the user. Don't like the generated code, just declare the method or property in the type and the generator will not generate it.
Constraints from DataAnnotations attributes. Declare
System.ComponentModel.DataAnnotationsvalidation attributes on the positional parameter, for example[EmailAddress, StringLength(254)] string Value, and the generator writesIsValueValidfrom them. See Getting started.ASP.NET Core validation. Declare
IValidatableObjecton the partial declaration and the generator writesValidatefrom the constraints, so a hand-writtenIsValueValidis reported by ASP.NET Core validation as a 400 instead of slipping through. See ASP.NET Core validation.Interoperable with other source generators. The "Value" property is visible to them and can be used in their generated code.
Generates implementation of the following interfaces, if the underlying type supports them:
System.IParsable<TSelf>System.ISpanParsable<TSelf>System.IUtf8SpanParsable<TSelf>System.IComparable<TSelf>System.IComparableSystem.IFormattableSystem.ISpanFormattableSystem.IUtf8SpanFormattable
Supported primitive types (among others):
stringintdecimallongdoubleGuidDateTimeDateTimeOffsetTimeOnlyDateOnlyTimeSpanbyte
All types are marked with
IStronglyTypedPrimitive,IStronglyTypedPrimitive<TPrimitiveType>andIStronglyTypedPrimitive<TSelf, TPrimitiveType>.System.Text.Json support. Every type is declared with a
[JsonConverter]pointing at the shared, trim/AOT-safeStronglyTypedJsonConverter<TSelf, TPrimitiveType>, if the target type is in an assembly that referencesSystem.Text.Jsonand the type does not already have aJsonConverterattribute declared on it. The converter serializes the type as its primitive value, also when used as a dictionary key, and dictionary keys are culture invariant. See System.Text.Json for how to use it with aJsonSerializerContext.OpenAPI support. The library includes a custom schema transformer that will ensure strongly typed types have the right OpenAPI schema definition. See OpenAPI support.
Getting started
To get started, download the nuget StronglyTypedPrimitives and
add a [StronglyTyped] attribute to a partial record struct that has one of the
supported primitive types as the first (and only) argument in it's constructor, for example:
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct StronglyTypedInt(int Value);
To constrain what values are legal for an strongly-typed primitive, implement the IsValueValid method:
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct StronglyTypedIntWithConstraints(int Value)
{
public static bool IsValueValid(int value, bool throwIfInvalid)
{
if (value > 5)
return true;
if (throwIfInvalid)
throw new ArgumentException("Value must be at larger than 5", nameof(value));
return false;
}
}
The generated type will ensure that the value is always valid or Empty, i.e.:
var tooLowValue = 5;
var goodValue = 6;
// The default value for a stringly typed primitive is the same as `Empty`.
// This makes it easy to test if an instance is valid or not.
Assert.Equal(StronglyTypedIntWithConstraints.Empty, default(StronglyTypedIntWithConstraints));
// Creating an instance with an invalid value results in an exception, both
// when instantiating an new instance of when cloning/with'ing the record.
Assert.Throws<ArgumentException>(() => new StronglyTypedIntWithConstraints(tooLowValue));
Assert.Throws<ArgumentException>(() => StronglyTypedIntWithConstraints.Empty with { Value = tooLowValue });
Instead of writing IsValueValid by hand, declare validation attributes from System.ComponentModel.DataAnnotations on the positional parameter and the generator writes the method for you:
using System.ComponentModel.DataAnnotations;
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct Email([EmailAddress, StringLength(254, MinimumLength = 3)] string Value);
Every attribute deriving from ValidationAttribute that targets the parameter itself is evaluated in declaration order. An invalid value throws a ValidationException whose message lists the error message of every failing attribute, one per line, and whose Value is the rejected value; TryParse and JSON deserialization return false/Empty as with a hand-written IsValueValid. See Generator output for string with validation attributes for the generated code.
A hand-written IsValueValid still wins: when the type declares the method, the attributes are not evaluated and the generator reports warning STP002 on each of them. Attributes deriving from AsyncValidationAttribute (.NET 11) cannot run inside the synchronous IsValueValid and are left out; unless the type declares IAsyncValidatableObject, which runs them through ValidateAsync as described in Async validation (.NET 11), the generator reports warning STP003 on each of them. Attributes that override RequiresValidationContext, such as CustomValidation, need a ValidationContext that IsValueValid does not have; they are left out as well with warning STP005.
Supported target frameworks
The package ships runtime assets for net11.0, net10.0 and netstandard2.0; the generator itself runs in any compiler host. What a project gets depends on the asset it resolves:
net10.0and later: everything described in this README, including the generated[JsonConverter], the sharedStronglyTypedJsonConverter<TSelf, TPrimitiveType>and the OpenAPI schema transformer.netstandard2.0(for example .NET Framework or .NET Standard class libraries): the[StronglyTyped]attribute and theIStronglyTypedPrimitiveinterfaces, without their static abstract members. The generated types still getValue,Create,IsValueValid, parsing and the constraint checks, but no generated JSON support: the generator reports warningSTP004instead (see Compatibility), and there is no OpenAPI transformer.
net8.0 and net9.0 were supported by versions before 2.0; both leave support in November 2026.
System.Text.Json
How a strongly typed primitive is serialized depends on how you use System.Text.Json:
Reflection-based serialization (
JsonSerializer.Serialize(value)without a context): nothing to do. The generated[JsonConverter]attribute is picked up automatically.JsonSerializerContexton a JIT runtime: theSystem.Text.Jsonsource generator cannot see attributes emitted by other source generators, so it would serialize the type as an object ({"Value":7}). Register the factory in the options and the context will use the shared converter for every strongly typed primitive:var options = new JsonSerializerOptions { TypeInfoResolver = AppJsonContext.Default, Converters = { new StronglyTypedJsonConverterFactory() }, };JsonSerializerContextwith trimming or Native AOT: the factory closes the generic converter with reflection, so declare the converter on your own partial declaration instead. The generator then emits nothing JSON-related for that type:[StronglyTyped] [JsonConverter(typeof(StronglyTypedJsonConverter<StronglyTypedInt, int>))] public readonly partial record struct StronglyTypedInt(int Value);
The generator reports warning STP001 for every strongly typed primitive without a user-declared [JsonConverter] when the compilation also contains a JsonSerializerContext, so the object-shaped output from option 2 and 3 does not go unnoticed.
Compatibility
StronglyTypedJsonConverter<TSelf, TPrimitiveType> and StronglyTypedJsonConverterFactory ship in the net10.0 and net11.0 assets of the package only. Projects that pick the netstandard2.0 asset (for example .NET Framework or .NET Standard class libraries) get no generated JSON support, even when they reference System.Text.Json: the generator emits no [JsonConverter] attribute for them and reports warning STP004 instead. Declare your own [JsonConverter] on the partial declaration to serialize the type there, or target net10.0 or later. Versions before 2.0 generated a nested converter for every target framework.
ASP.NET Core validation
An invalid value in a request body is deserialized to Empty by the JSON converter rather than failing the request, so the bound object carries the default value and only validation can reject it. A route or query value that fails TryParse is different: that is a binding failure, and ASP.NET Core answers 400 before the handler runs, with an empty body. When validation is registered (builder.Services.AddValidation(), .NET 10) the endpoint filter pipeline still runs with the parameter at its default, so that 400 carries the validation problem details for Empty instead. Which constraints validation sees depends on how they are expressed, because its validation source generator only sees your own declarations, never the partial this generator adds:
Validation attributes on the positional parameter are visible to it on the
Valueproperty, so it validates them itself. An invalidQuantityproperty of typeStronglyTypedQuantity([Range(6, 100)] int Value)is reported underQuantity.Valuewith the attribute's message. Nothing extra is needed.A hand-written
IsValueValidis invisible to it, so the type is not validated at all. To fix that, declareSystem.ComponentModel.DataAnnotations.IValidatableObjecton your partial declaration:[StronglyTyped] public readonly partial record struct StronglyTypedIntWithConstraints(int Value) : IValidatableObject { public static bool IsValueValid(int value, bool throwIfInvalid) { if (value > 5) return true; if (throwIfInvalid) throw new ArgumentException("Value must be larger than 5", nameof(value)); return false; } }The generator fills in
Validate, which callsIsValueValid(Value, throwIfInvalid: true)and reports the message of theArgumentExceptionorValidationExceptionit throws as aValidationResult(or"Value is not valid."when the method returnsfalsewithout throwing). Because the runtime callsValidatewithout a member name, .NET 10 records the result under the empty key in the problem details.
Both only reject a request body when Empty itself violates the constraints. The conversion is lossy: the JSON converter replaces the invalid value with Empty and keeps nothing of the original, so the generated Validate cannot recover the failure that was in the request and can only report what Empty fails. A Percentage([Range(0, 100)] int Value) : IValidatableObject bound from {"value":101} is accepted with a Value of 0: 101 became Empty, and zero is within range. When strict input validation is needed, the constraints must reject the default value (a [Range(1, 100)] or a [Required] string does), or invalid JSON must be rejected at conversion time, which this package does not do today.
The generator never adds IValidatableObject on its own: the validation source generator would not see it, so the declaration has to be yours. A Validate you write yourself, implicitly or as an explicit interface implementation, is left alone like every other generated member. Validate is generated for every type that declares the interface, so a type with validation attributes gets one that evaluates the attributes, which is what Validator.TryValidateObject and other IValidatableObject consumers see. Given:
using System.ComponentModel.DataAnnotations;
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct Email([EmailAddress, StringLength(254, MinimumLength = 3)] string Value) : IValidatableObject;
the following Validate is generated next to the IsValueValid shown in Generator output for string with validation attributes:
public global::System.Collections.Generic.IEnumerable<global::System.ComponentModel.DataAnnotations.ValidationResult> Validate(global::System.ComponentModel.DataAnnotations.ValidationContext validationContext)
{
var result0 = ValueValidators.valueValidator0.GetValidationResult(this.Value, validationContext);
if (result0 == global::System.ComponentModel.DataAnnotations.ValidationResult.Success) result0 = null;
var result1 = ValueValidators.valueValidator1.GetValidationResult(this.Value, validationContext);
if (result1 == global::System.ComponentModel.DataAnnotations.ValidationResult.Success) result1 = null;
if (result0 is null && result1 is null) return global::System.Array.Empty<global::System.ComponentModel.DataAnnotations.ValidationResult>();
var results = new global::System.ComponentModel.DataAnnotations.ValidationResult[(result0 is null ? 0 : 1) + (result1 is null ? 0 : 1)];
var index = 0;
if (result0 is not null) results[index++] = result0;
if (result1 is not null) results[index++] = result1;
return results;
}
Every attribute is evaluated so one result per failing attribute is returned, and a valid value returns an empty array without allocating. Attributes that require a ValidationContext (STP005) are evaluated here as well, with the context the caller passes, after the ordinary attributes or the hand-written IsValueValid; Validate is the one generated member that has a context to give them. Attributes deriving from AsyncValidationAttribute are not part of Validate; see Async validation (.NET 11). In ASP.NET Core validation the attribute errors on Value take precedence: Validate is only consulted when no member has failed, so the two never report the same failure twice. If the type already has a member named Validate, say a positional parameter of that name, the generated method implements IValidatableObject.Validate explicitly instead.
Async validation (.NET 11)
.NET 11 adds AsyncValidationAttribute and IAsyncValidatableObject for constraints whose answer is only available asynchronously, such as a uniqueness check against a database. An async attribute on the positional parameter is not part of the value invariant: constructors, init accessors, Parse, TryParse and the JSON converter cannot await, so IsValueValid and Validate ignore it, and a value it would reject can still be constructed. To have it evaluated, declare IAsyncValidatableObject on your partial declaration:
using System.ComponentModel.DataAnnotations;
using Egil.StronglyTypedPrimitives;
namespace Examples;
public sealed class NotReservedAttribute() : AsyncValidationAttribute("The {0} field must not be reserved.")
{
protected override async Task<ValidationResult?> IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken)
{
await Task.Yield(); // ask the database here
return string.Equals(value as string, "reserved", StringComparison.OrdinalIgnoreCase)
? new ValidationResult(FormatErrorMessage(validationContext.DisplayName))
: ValidationResult.Success;
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
=> throw new NotSupportedException();
}
[StronglyTyped]
public readonly partial record struct Username([StringLength(10, MinimumLength = 2), NotReserved] string Value) : IAsyncValidatableObject;
IAsyncValidatableObject extends IValidatableObject, so Validate is generated as described above, and next to it the generator adds one field per async attribute to the ValueValidators class and emits ValidateAsync:
private static class ValueValidators
{
public static readonly global::System.ComponentModel.DataAnnotations.StringLengthAttribute valueValidator0 = new global::System.ComponentModel.DataAnnotations.StringLengthAttribute(10) { MinimumLength = 2 };
public static readonly global::Examples.NotReservedAttribute asyncValueValidator0 = new global::Examples.NotReservedAttribute();
public static global::System.ComponentModel.DataAnnotations.ValidationContext CreateInvariantContext()
=> new global::System.ComponentModel.DataAnnotations.ValidationContext(new object(), "Value", null, null) { MemberName = "Value" };
}
public async global::System.Collections.Generic.IAsyncEnumerable<global::System.ComponentModel.DataAnnotations.ValidationResult> ValidateAsync(global::System.ComponentModel.DataAnnotations.ValidationContext validationContext, [global::System.Runtime.CompilerServices.EnumeratorCancellation] global::System.Threading.CancellationToken cancellationToken = default)
{
foreach (var result in Validate(validationContext))
{
yield return result;
}
var asyncResult0 = await ValueValidators.asyncValueValidator0.GetValidationResultAsync(this.Value, validationContext, cancellationToken).ConfigureAwait(false);
if (asyncResult0 is not null && asyncResult0 != global::System.ComponentModel.DataAnnotations.ValidationResult.Success) yield return asyncResult0;
}
ValidateAsync yields everything Validate reports first, then awaits every async attribute in declaration order so one call reports all failures, forwarding the cancellation token it was given to each attribute. This is what Validator.TryValidateObjectAsync and other IAsyncValidatableObject consumers see. The rules for Validate apply unchanged: the generator never adds the interface itself, a ValidateAsync you write, implicitly or as an explicit interface implementation, is left alone, and when the type already has a member named ValidateAsync the generated method implements IAsyncValidatableObject.ValidateAsync explicitly. The public form declares the cancellation token optional like the interface does, so value.ValidateAsync(validationContext) compiles; the explicit form cannot carry a default and is only reachable through the interface, whose default applies.
In ASP.NET Core validation on .NET 11 an async attribute on the positional parameter is treated like a synchronous one: the validation source generator sees it on Value and evaluates it itself, so an invalid Name property of type Username is rejected with a 400 whose problem details carry the attribute's message under Name.Value, exactly once. Without a declared IAsyncValidatableObject that ASP.NET Core path still works, but nothing this generator emits evaluates the attribute (IsValueValid and Validate cannot await) and Validator.TryValidateObjectAsync has no ValidateAsync to call; warning STP003 points this out.
With IAsyncValidatableObject declared, ASP.NET Core evaluates a valid async attribute twice per request: once on Value by the validation source generator and, because every member passed, once more inside the generated ValidateAsync. The framework hands ValidateAsync an ordinary ValidationContext (no MemberName, empty Items, nothing reachable through GetService that says the attributes were already checked), so the generated method cannot tell that call from one made by Validator.TryValidateObjectAsync and does not try to skip the attribute. An invalid value is evaluated once, because the attribute error on Value stops the framework before ValidateAsync. If ASP.NET Core validation is all you need, leave IAsyncValidatableObject undeclared: the framework evaluates the attribute on Value regardless, and STP003 only reminds you that nothing this generator emits runs it. If Validator.TryValidateObjectAsync or another IAsyncValidatableObject consumer must see the attribute too, declare the interface and either accept the second evaluation on valid requests or let the attribute cache its answer per value. Validator itself evaluates the attribute once: reflection finds no attributes on the Value property, so under it the generated ValidateAsync is the only place the attribute runs.
Generator output for int without constraints
Given this type declaration:
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct StronglyTypedInt(int Value);
The following code is generated:
#nullable enable
namespace Examples;
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Egil.StronglyTypedPrimitives, Version=1.14.0.0, Culture=neutral, PublicKeyToken=null", "1.14.0.0")]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Egil.StronglyTypedPrimitives.StronglyTypedJsonConverter<global::Examples.StronglyTypedInt, int>))]
public readonly partial record struct StronglyTypedInt : global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<int>, global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<global::Examples.StronglyTypedInt, int>, global::System.IParsable<global::Examples.StronglyTypedInt>, global::System.ISpanParsable<global::Examples.StronglyTypedInt>, global::System.IUtf8SpanParsable<global::Examples.StronglyTypedInt>, global::System.IComparable<global::Examples.StronglyTypedInt>, global::System.IComparable, global::System.IFormattable, global::System.ISpanFormattable, global::System.IUtf8SpanFormattable
{
public static readonly StronglyTypedInt Empty = default;
public static StronglyTypedInt Create(int value) => new StronglyTypedInt(value);
public override string ToString() => Value.ToString();
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static bool IsValueValid(int value, bool throwIfInvalid)
=> true;
public static StronglyTypedInt Parse(string s, global::System.IFormatProvider? provider)
{
var rawValue = int.Parse(s, provider);
IsValueValid(rawValue, throwIfInvalid: true);
return new StronglyTypedInt(rawValue);
}
public static bool TryParse(string? s, global::System.IFormatProvider? provider, [global::System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute(returnValue: false)] out global::Examples.StronglyTypedInt result)
{
if (int.TryParse(s, provider, out var rawValue) && IsValueValid(rawValue, throwIfInvalid: false))
{
result = new StronglyTypedInt(rawValue);
return true;
}
result = StronglyTypedInt.Empty;
return false;
}
public static StronglyTypedInt Parse(global::System.ReadOnlySpan<char> s, global::System.IFormatProvider? provider)
{
var rawValue = int.Parse(s, provider);
IsValueValid(rawValue, throwIfInvalid: true);
return new StronglyTypedInt(rawValue);
}
public static bool TryParse(global::System.ReadOnlySpan<char> s, global::System.IFormatProvider? provider, [global::System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute(returnValue: false)] out global::Examples.StronglyTypedInt result)
{
if (int.TryParse(s, provider, out var rawValue) && IsValueValid(rawValue, throwIfInvalid: false))
{
result = new StronglyTypedInt(rawValue);
return true;
}
result = StronglyTypedInt.Empty;
return false;
}
public static StronglyTypedInt Parse(global::System.ReadOnlySpan<byte> utf8Text, global::System.IFormatProvider? provider)
{
var rawValue = int.Parse(utf8Text, provider);
IsValueValid(rawValue, throwIfInvalid: true);
return new StronglyTypedInt(rawValue);
}
public static bool TryParse(global::System.ReadOnlySpan<byte> utf8Text, global::System.IFormatProvider? provider, [global::System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute(returnValue: false)] out global::Examples.StronglyTypedInt result)
{
if (int.TryParse(utf8Text, provider, out var rawValue) && IsValueValid(rawValue, throwIfInvalid: false))
{
result = new StronglyTypedInt(rawValue);
return true;
}
result = StronglyTypedInt.Empty;
return false;
}
public int CompareTo(global::Examples.StronglyTypedInt other)
=> Value.CompareTo(other.Value);
public int CompareTo(object? obj)
{
if (obj is null)
{
return 1;
}
if (obj is StronglyTypedInt other)
{
return Value.CompareTo(other.Value);
}
return ((global::System.IComparable)Value).CompareTo(obj);
}
public string ToString(string? format, global::System.IFormatProvider? formatProvider)
=> Value.ToString(format, formatProvider);
public bool TryFormat(global::System.Span<char> destination, out int charsWritten, global::System.ReadOnlySpan<char> format, global::System.IFormatProvider? provider)
=> ((global::System.ISpanFormattable)Value).TryFormat(destination, out charsWritten, format, provider);
public bool TryFormat(global::System.Span<byte> utf8Destination, out int bytesWritten, global::System.ReadOnlySpan<char> format, global::System.IFormatProvider? provider)
=> ((global::System.IUtf8SpanFormattable)Value).TryFormat(utf8Destination, out bytesWritten, format, provider);
public static bool operator > (StronglyTypedInt a, StronglyTypedInt b) => a.CompareTo(b) > 0;
public static bool operator < (StronglyTypedInt a, StronglyTypedInt b) => a.CompareTo(b) < 0;
public static bool operator >=(StronglyTypedInt a, StronglyTypedInt b) => a.CompareTo(b) >= 0;
public static bool operator <=(StronglyTypedInt a, StronglyTypedInt b) => a.CompareTo(b) <= 0;
}
When the positional parameter is not named Value, an explicit implementation of IStronglyTypedPrimitive<TSelf, TPrimitiveType>.Value is generated as well, for example int global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<global::Examples.StronglyTypedInt, int>.Value => Data;.
See more examples in https://github.com/egil/framework/tree/main/Egil.StronglyTypedPrimitives/test/Egil.StronglyTypedPrimitives.Tests
Generator output for int with constraints
Given this type declaration:
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct StronglyTypedIntWithConstraints(int Value)
{
public static bool IsValueValid(int value, bool throwIfInvalid)
{
if (value > 5)
return true;
if (throwIfInvalid)
throw new ArgumentException("Value must be at larger than 5", nameof(value));
return false;
}
}
The following code is generated when using C# 13 or below:
#nullable enable
namespace Examples;
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Egil.StronglyTypedPrimitives, Version=1.14.0.0, Culture=neutral, PublicKeyToken=null", "1.14.0.0")]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Egil.StronglyTypedPrimitives.StronglyTypedJsonConverter<global::Examples.StronglyTypedIntWithConstraints, int>))]
public readonly partial record struct StronglyTypedIntWithConstraints : global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<int>, global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<global::Examples.StronglyTypedIntWithConstraints, int>, global::System.IParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.ISpanParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IUtf8SpanParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IComparable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IComparable, global::System.IFormattable, global::System.ISpanFormattable, global::System.IUtf8SpanFormattable
{
public static readonly StronglyTypedIntWithConstraints Empty = default;
public static StronglyTypedIntWithConstraints Create(int value) => new StronglyTypedIntWithConstraints(value);
private static int ThrowIfValueIsInvalid(int value)
{
IsValueValid(value, throwIfInvalid: true);
return value;
}
private readonly int @value_ = ThrowIfValueIsInvalid(Value);
public int Value
{
get => @value_;
init
{
@value_ = ThrowIfValueIsInvalid(value);
}
}
// remaining cut for brevity. same as first example above ...
}
The following code is generated when using C# 14 or higher:
#nullable enable
namespace Examples;
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Egil.StronglyTypedPrimitives, Version=1.14.0.0, Culture=neutral, PublicKeyToken=null", "1.14.0.0")]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Egil.StronglyTypedPrimitives.StronglyTypedJsonConverter<global::Examples.StronglyTypedIntWithConstraints, int>))]
public readonly partial record struct StronglyTypedIntWithConstraints : global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<int>, global::Egil.StronglyTypedPrimitives.IStronglyTypedPrimitive<global::Examples.StronglyTypedIntWithConstraints, int>, global::System.IParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.ISpanParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IUtf8SpanParsable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IComparable<global::Examples.StronglyTypedIntWithConstraints>, global::System.IComparable, global::System.IFormattable, global::System.ISpanFormattable, global::System.IUtf8SpanFormattable
{
public static readonly StronglyTypedIntWithConstraints Empty = default;
public static StronglyTypedIntWithConstraints Create(int value) => new StronglyTypedIntWithConstraints(value);
private static int ThrowIfValueIsInvalid(int value)
{
IsValueValid(value, throwIfInvalid: true);
return value;
}
public int Value
{
get => field;
init
{
field = ThrowIfValueIsInvalid(value);
}
} = ThrowIfValueIsInvalid(Value);
// remaining cut for brevity. same as first example above ...
}
Generator output for string with validation attributes
Given this type declaration:
using System.ComponentModel.DataAnnotations;
using Egil.StronglyTypedPrimitives;
namespace Examples;
[StronglyTyped]
public readonly partial record struct Email([EmailAddress, StringLength(254, MinimumLength = 3)] string Value);
The Value property and ThrowIfValueIsInvalid are generated exactly as in the constraints example above, and IsValueValid is generated from the attributes:
private static class ValueValidators
{
public static readonly global::System.ComponentModel.DataAnnotations.EmailAddressAttribute valueValidator0 = new global::System.ComponentModel.DataAnnotations.EmailAddressAttribute();
public static readonly global::System.ComponentModel.DataAnnotations.StringLengthAttribute valueValidator1 = new global::System.ComponentModel.DataAnnotations.StringLengthAttribute(254) { MinimumLength = 3 };
public static global::System.ComponentModel.DataAnnotations.ValidationContext CreateInvariantContext()
=> new global::System.ComponentModel.DataAnnotations.ValidationContext(new object(), "Value", null, null) { MemberName = "Value" };
}
public static bool IsValueValid(string value, bool throwIfInvalid)
{
var context = ValueValidators.CreateInvariantContext();
string? error0 = null;
string? error1 = null;
if (ValueValidators.valueValidator0.GetValidationResult(value, context) is { } result0)
{
if (!throwIfInvalid) return false;
error0 = result0.ErrorMessage ?? "The field Value is invalid.";
}
if (ValueValidators.valueValidator1.GetValidationResult(value, context) is { } result1)
{
if (!throwIfInvalid) return false;
error1 = result1.ErrorMessage ?? "The field Value is invalid.";
}
if (error0 is null && error1 is null) return true;
var message = string.Empty;
if (error0 is not null) message = error0;
if (error1 is not null) message = message.Length == 0 ? error1 : message + global::System.Environment.NewLine + error1;
throw new global::System.ComponentModel.DataAnnotations.ValidationException(message, null, value);
}
With throwIfInvalid: false the method returns at the first failing attribute. With throwIfInvalid: true every attribute is evaluated so the exception reports all of them at once. Every attribute is evaluated through GetValidationResult with a ValidationContext created for that call, whose MemberName and DisplayName are the name of the positional parameter and whose ObjectInstance is a placeholder object, so attributes that override either IsValid overload work, and error messages come out formatted with the parameter name. A failing attribute that produces no message at all (a ValidationResult with a null ErrorMessage and a FormatErrorMessage that returns null) is still a failure, reported with DataAnnotations' default wording The field Value is invalid.. The context is not shared between calls because it is mutable and an attribute may write to its Items; the cost is one small allocation per validated construction or parse of an attribute-constrained type, and none for types without attributes. The context is created with the trim-safe ValidationContext constructor that .NET 10 added; on the netstandard2.0 asset, which has no such constructor and no trim analysis, CreateInvariantContext calls ValidationContext(object) with DisplayName set instead (which keeps its reflection fallback from running). The attribute instances live in a nested ValueValidators class (suffixed with underscores if the type already has a member of that name) so that they are initialized on first use, even from a static initializer on the type itself such as public static readonly Email Default = new("a@b.c");.
OpenAPI support
The library includes a custom schema transformer (in the net10.0 and net11.0 assets) that documents strongly typed types with the OpenAPI schema of the primitive they wrap, wherever they appear: as body properties, as array items and dictionary values, and as route or query parameters. It asks the OpenAPI pipeline for the primitive's own schema, so the wrapper is documented exactly as ASP.NET Core documents the primitive on that framework. To use it, add the following to your OpenApi options:
using Egil.StronglyTypedPrimitives;
builder.Services.AddOpenApi(options =>
{
options.AddSchemaTransformer<StronglyTypedSchemaTransformer>();
}
Strongly typed primitives are emitted as components and referenced with $ref wherever they are used, like any other non-primitive type. ASP.NET Core keeps validation attributes such as [RegularExpression] on a property only when that property's schema is inline, so to see those attributes documented on strongly typed properties, inline the wrappers instead:
options.CreateSchemaReferenceId = typeInfo =>
typeof(IStronglyTypedPrimitive).IsAssignableFrom(Nullable.GetUnderlyingType(typeInfo.Type) ?? typeInfo.Type)
? null
: OpenApiOptions.CreateDefaultSchemaReferenceId(typeInfo);
Alternatives
There are other alternatives to this source generator that you can consider if you need something different:
Both are excellent and I have used them in the past.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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. net11.0 is compatible. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2.0.56 | 115 | 9/22/2026 |
| 1.13.3 | 4,990 | 10/17/2025 |
| 1.12.3 | 205 | 10/17/2025 |
| 1.11.5 | 711 | 8/11/2025 |
| 1.10.4 | 879 | 4/14/2025 |
| 1.9.7 | 1,245 | 4/4/2025 |
| 1.8.4 | 218 | 4/4/2025 |
| 1.7.3 | 260 | 4/4/2025 |
| 1.6.5 | 250 | 4/4/2025 |
| 1.5.3 | 266 | 4/1/2025 |
| 1.3.19 | 277 | 3/20/2025 |
| 1.3.18 | 289 | 3/19/2025 |
| 1.3.17-alpha-g0068e22c34 | 260 | 3/19/2025 |
| 1.3.16-alpha-g07becff3bc | 251 | 3/19/2025 |
| 1.3.15-alpha-gd675b7ec70 | 242 | 3/19/2025 |
| 1.3.14-alpha-gc2c32e4281 | 270 | 3/19/2025 |
| 1.3.13-alpha-gabddf5bf54 | 244 | 3/19/2025 |
| 1.3.1-alpha.0.1 | 234 | 3/18/2025 |
| 1.2.0 | 253 | 3/18/2025 |
| 1.1.1-alpha.0.2 | 230 | 3/18/2025 |