Egil.StronglyTypedPrimitives 2.0.56

dotnet add package Egil.StronglyTypedPrimitives --version 2.0.56
                    
NuGet\Install-Package Egil.StronglyTypedPrimitives -Version 2.0.56
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Egil.StronglyTypedPrimitives" Version="2.0.56" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Egil.StronglyTypedPrimitives" Version="2.0.56" />
                    
Directory.Packages.props
<PackageReference Include="Egil.StronglyTypedPrimitives" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Egil.StronglyTypedPrimitives --version 2.0.56
                    
#r "nuget: Egil.StronglyTypedPrimitives, 2.0.56"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Egil.StronglyTypedPrimitives@2.0.56
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Egil.StronglyTypedPrimitives&version=2.0.56
                    
Install as a Cake Addin
#tool nuget:?package=Egil.StronglyTypedPrimitives&version=2.0.56
                    
Install as a Cake Tool

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 IsValueValid method), or equal to Empty.

  • 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.DataAnnotations validation attributes on the positional parameter, for example [EmailAddress, StringLength(254)] string Value, and the generator writes IsValueValid from them. See Getting started.

  • ASP.NET Core validation. Declare IValidatableObject on the partial declaration and the generator writes Validate from the constraints, so a hand-written IsValueValid is 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.IComparable
    • System.IFormattable
    • System.ISpanFormattable
    • System.IUtf8SpanFormattable
  • Supported primitive types (among others):

    • string
    • int
    • decimal
    • long
    • double
    • Guid
    • DateTime
    • DateTimeOffset
    • TimeOnly
    • DateOnly
    • TimeSpan
    • byte
  • All types are marked with IStronglyTypedPrimitive, IStronglyTypedPrimitive<TPrimitiveType> and IStronglyTypedPrimitive<TSelf, TPrimitiveType>.

  • System.Text.Json support. Every type is declared with a [JsonConverter] pointing at the shared, trim/AOT-safe StronglyTypedJsonConverter<TSelf, TPrimitiveType>, if the target type is in an assembly that references System.Text.Json and the type does not already have a JsonConverter attribute 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 a JsonSerializerContext.

  • 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.0 and later: everything described in this README, including the generated [JsonConverter], the shared StronglyTypedJsonConverter<TSelf, TPrimitiveType> and the OpenAPI schema transformer.
  • netstandard2.0 (for example .NET Framework or .NET Standard class libraries): the [StronglyTyped] attribute and the IStronglyTypedPrimitive interfaces, without their static abstract members. The generated types still get Value, Create, IsValueValid, parsing and the constraint checks, but no generated JSON support: the generator reports warning STP004 instead (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:

  1. Reflection-based serialization (JsonSerializer.Serialize(value) without a context): nothing to do. The generated [JsonConverter] attribute is picked up automatically.

  2. JsonSerializerContext on a JIT runtime: the System.Text.Json source 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() },
    };
    
  3. JsonSerializerContext with 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 Value property, so it validates them itself. An invalid Quantity property of type StronglyTypedQuantity([Range(6, 100)] int Value) is reported under Quantity.Value with the attribute's message. Nothing extra is needed.

  • A hand-written IsValueValid is invisible to it, so the type is not validated at all. To fix that, declare System.ComponentModel.DataAnnotations.IValidatableObject on 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 calls IsValueValid(Value, throwIfInvalid: true) and reports the message of the ArgumentException or ValidationException it throws as a ValidationResult (or "Value is not valid." when the method returns false without throwing). Because the runtime calls Validate without 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .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