Skip to content

Nested and collection validation

How child objects and collections are walked, and exactly how error property paths are named.

4 min read

Validation recurses. A property whose type is itself marked [Validate] is validated as part of its parent, and collections are walked element by element, to arbitrary depth.

Nested validation targets

Command.cs
[Validate]
public sealed partial record Command : IValidationTarget<Command>
{
	public required Address Address { get; init; }
}

[Validate]
public sealed partial record Address : IValidationTarget<Address>
{
	[NotEmpty]
	public required string Street { get; init; }
}

Validating a Command whose Address.Street is empty produces a single error with PropertyName of Address.Street — the child’s own path, prefixed with the parent property. Nothing needs to be declared to enable this; it follows from Address carrying [Validate].

A nullable nested target that is null is simply skipped. A non-nullable one that is null fails its implicit null check and is not recursed into.

Collections

Properties typed as an array, ICollection<T>, IReadOnlyCollection<T>, or anything implementing one of those interfaces (List<T>, IReadOnlyList<T>, HashSet<T>, …) are walked element by element.

Command.cs
[Validate]
public sealed partial record Command : IValidationTarget<Command>
{
	public required IReadOnlyList<Address> Addresses { get; init; }
}

Each element is validated in turn, and errors carry an index: Addresses[2].Street. Nested collections nest their indexes: Matrix[0][1].

Validating elements instead of the collection

An untargeted validator attribute on a collection property applies to the collection itself, and to every nested collection level. To validate the elements, use the element: attribute target.

Command.cs
[Validate]
public sealed partial record Command : IValidationTarget<Command>
{
	// `Tags` itself must be non-empty; individual tags are unconstrained
	[NotEmpty]
	public required IReadOnlyList<string> Tags { get; init; }

	// each individual name must be non-empty; the list itself is unconstrained
	[element: NotEmpty]
	public required IReadOnlyList<string> Names { get; init; }
}

element: always targets the innermost element type. On IReadOnlyList<IReadOnlyList<string>>, [element: NotEmpty] applies to each string, while a plain [NotEmpty] applies to the outer list and to each inner list.

On a positional record, combine the two targets:

Command.cs
[Validate]
public sealed partial record Command(
	[property: NotEmpty]
	[element: GreaterThan(0)]
	IReadOnlyList<int> Values
) : IValidationTarget<Command>;

Interfaces, base classes, and visit deduplication

Base classes and [Validate] interfaces contribute their validators to the derived type. Because a type can reach the same interface by more than one path, the shared ValidationResult tracks which types it has already visited and short-circuits repeats:

Interfaces.cs
[Validate]
public partial interface IBase : IValidationTarget<IBase>;

[Validate]
public partial interface ISub : IBase, IValidationTarget<ISub>;

[Validate]
public sealed partial record Command : IBase, ISub, IValidationTarget<Command>;

IBase’s validators run once, not twice.

Property path rules

ValidationError.PropertyName is the machine-readable path, built from these rules. If you are mapping errors onto form fields, this is the contract.

SituationPropertyName
Property on the validated typeStreet
Property of a nested [Validate] typeAddress.Street
Element of a collectionAddresses[2]
Property of a collection elementAddresses[2].Street
Nested collectionsMatrix[0][1]
Property inherited from a base type or interfaceBaseString (no prefix)
The root target itself was null.self

The .self sentinel is used when a reference-typed target passed to Validate is null. The result contains exactly one error:

PropertyName  = ".self"
ErrorMessage  = "`target` must not be `null`."

Display names are separate

PropertyName is the path; the {PropertyName} token inside a message is a human-readable display name and follows different rules — it is the property identifier with a space inserted before each capital that follows a non-capital (StringValueString Value), or the value of [Description] if present, with any [index] suffixes appended. Display names are not prefixed with the parent path, so a failure at Addresses[2].Street reads 'Street' must not be empty. while its PropertyName is Addresses[2].Street.

See Custom validation messages for the full token list, and Handling validation failures for turning these paths into a ValidationProblemDetails.