Skip to main content

Getting Started

Installation​

Add the core library to your project. The integration and testing packages are optional.

Core library (required)

dotnet add package ZeroAlloc.Validation

ZeroAlloc.Validation bundles its own source generator — no other package is required. Upgrading? Remove any direct ZeroAlloc.Validation.Generator reference — keeping it alongside ZeroAlloc.Validation loads the generator twice and fails the build with ZV9001.

ASP.NET Core auto-validation (optional)

dotnet add package ZeroAlloc.Validation.AspNetCore

Test assertion helpers (optional)

dotnet add package ZeroAlloc.Validation.Testing

How it works​

ZeroAlloc.Validation uses a Roslyn source generator that runs at compile time, not at runtime. It inspects your annotated models and emits a concrete validator class (e.g. RegisterUserRequestValidator) that extends ValidatorFor<T>. No reflection, no expression trees — pure IL at build time. The result has no allocations on the hot path.

Annotate your model​

Apply [Validate] to a class, then annotate each property with the constraint attributes you need:

using ZeroAlloc.Validation;

[Validate]
public class RegisterUserRequest
{
[NotEmpty][MaxLength(100)] public string Username { get; set; } = "";
[NotEmpty][MinLength(8)] public string Password { get; set; } = "";
[NotEmpty][EmailAddress] public string Email { get; set; } = "";
}

The generator emits RegisterUserRequestValidator in the same namespace as your model. For flat models like this one (no nested validated properties), the generated validator has a parameterless constructor.

Models declared inside another type. The validator is always a top-level class in the model's namespace. For a model nested in other types, its name starts with the containing types, outermost first, joined with underscores:

public static class Orders
{
[Validate] public class Request { [NotEmpty] public string Name { get; set; } = ""; }
}
// Generated: Orders_RequestValidator : ValidatorFor<Orders.Request>

A model in A.B gets A_B_RequestValidator. So Orders.Request, Returns.Request and a top-level Request in the same namespace each get their own validator. Plain concatenation, OrdersRequestValidator, would clash with the validator of a top-level OrdersRequest model; the underscore can only clash with a type name that itself contains an underscore, such as a top-level Orders_Request. Such a clash fails the build with ZV0031, and neither model gets a validator. The validator must be able to reach the model, so the model and every type containing it must be public, internal or protected internal. A private, protected or private protected nested model, a model inside such a type, or a file-local model or one nested inside it fails the build with ZV0025, and no validator is generated for it.

Generic models​

A generic model, or one declared inside a generic type, gets one validator, generic over the type parameters of the model and of every type containing it, with their declared names and constraints:

[Validate]
public class Page<TItem> where TItem : class
{
[NotEmpty] public string Title { get; set; } = "";
[NotNull] public TItem? Selected { get; set; }
public List<Line<TItem>> Lines { get; set; } = [];
}
// Generated: PageValidator<TItem> : ValidatorFor<Page<TItem>> where TItem : class

var validator = new PageValidator<Product>(new LineValidator<Product>());

Box and Box<T> get BoxValidator and BoxValidator<T>. A model inside a generic type, Envelope<T>.Header, gets Envelope_HeaderValidator<T>.

  • Rules on a type parameter must compile for every closing. [NotNull], [Null] and [Must] always do, the numeric comparisons do when the type parameter is constrained to INumberBase<T>, [IsInEnum] when it is struct, Enum, and a custom ValidationAttribute<TValue> when it converts to TValue. Any other built-in rule on a type parameter fails the build with ZV0036.
  • Nested models follow nested validation: a property of a closed type such as Page<Order> is composed as ValidatorFor<Page<Order>>, and a property whose type is a type parameter only through a [Validate] class it is constrained to.
  • Registration. AddZeroAllocValidators() registers each closing the models you register reach, such as Page<Order> held by a non-generic OrderPage, closed. A closing used only at the root is registered with the generated helper, services.AddPageValidator<Customer>(), and an options model with AddOptions<Page<Customer>>().ValidateWithZeroAlloc(); see Dependency injection and Options.
  • ASP.NET Core. A registered closing as an action argument, [FromBody] Page<Customer>, is validated by the filter, and an unregistered closing of one of your generic models throws; see ASP.NET Core.
  • Pipeline behaviors name the open form, AppliesTo = typeof(Page<>), and run for every closing; a closed form is reported as ZV0038.
  • NativeAOT. A value-type closing gets its own compiled instantiation, rooted by the closed registration or by new PageValidator<int>() in your code. There is no reflection and no MakeGenericType.

A type parameter whose name repeats along the containing chain, Outer<T>.Inner<T>, cannot be declared twice by the validator and fails the build with ZV0029. So does one named like the validator, or like a member it declares or reserves, such as Box<Validate>.

Target types. [Validate] works on class, record, readonly struct, and readonly record struct. Decorating a non-readonly struct or record struct emits ZV0014 (Warning) — a caller can mutate the instance between the validator returning success and the consumer reading the value, making the validation result stale. Prefer the readonly form for request types.

Call the validator​

Instantiate the generated validator and call Validate. The returned ValidationResult exposes IsValid and a zero-allocation Failures span:

var validator = new RegisterUserRequestValidator();

var result = validator.Validate(new RegisterUserRequest
{
Username = "",
Password = "abc",
Email = "not-an-email"
});

Console.WriteLine(result.IsValid); // false

foreach (ref readonly var failure in result.Failures)
Console.WriteLine($"[{failure.PropertyName}] {failure.ErrorMessage}");
// [Username] 'Username' must not be empty.
// [Password] 'Password' must be at least 8 characters.
// [Email] 'Email' is not a valid email address.

Each ValidationFailure carries:

  • PropertyName — the name of the property that failed
  • ErrorMessage — a human-readable description of the failure
  • ErrorCode — an optional machine-readable code
  • Severity — Error, Warning, or Info

Validating value-object properties​

If a property's type is decorated with [ZeroAlloc.ValueObjects.ValueObject] and exposes exactly one public instance property (a typed-id wrapper, for example), the built-in operand-style validators auto-unwrap through the wrapper. You annotate the wrapper; the generator emits the comparison against the underlying value.

using ZeroAlloc.Validation;
using ZeroAlloc.ValueObjects;

[ValueObject]
public readonly partial struct CustomerId
{
public int Value { get; }
public CustomerId(int value) => Value = value;
}

[Validate]
public readonly record struct PlaceOrderCommand(
[property: GreaterThan(0)] CustomerId CustomerId,
[property: GreaterThan(0)] decimal Total);

The generator emits instance.CustomerId.Value > 0 rather than trying to compare the wrapper itself — so [GreaterThan], [NotEmpty], [InclusiveBetween], and the other operand-taking attributes "just work" against typed ids and other single-property value-objects.

Predicate validators preserve the wrapper. [Must(nameof(IsKnown))] passes the wrapper into your method, not the unwrap — the method signature is what the user wrote:

[Validate]
public partial class PlaceOrderCommand
{
[Must(nameof(IsKnown))]
public CustomerId CustomerId { get; set; }

public bool IsKnown(CustomerId id) => id.Value > 0;
}

Multi-property value-objects. Auto-unwrap only applies when the wrapper has a single underlying property. If you put a built-in operand validator on a multi-property value-object (e.g. a Money { decimal Amount; string Currency; }), the generator emits ZV0016 (Warning) — there is no single property to unwrap through. Use [Must] with a custom predicate instead.

Next steps​