Skip to content

Enhancement: PathResolver.FindField — reflection-based fallback for nullable nested properties #95

@xfunc-tim-schneider

Description

@xfunc-tim-schneider

Problem

PathResolver.FindField returns null when a property in the navigation path is null (confirmed by the existing test FindField_WithNullObjectInPath_ReturnsNull in PathResolverTests.cs). The fallback in FluentValidator.ApplyValidationResults then creates:

var field = PathResolver.FindField(model, validationFailure.PropertyName)
    ?? new FieldIdentifier(model, validationFailure.PropertyName);

This produces FieldIdentifier(rootModel, "Address.City"), which doesn't match what Blazor's <ValidationMessage For="() => model.Address.City"> generates: FieldIdentifier(addressInstance, "City").

The result: Validation messages for nullable nested properties never display inline, because the FieldIdentifier from the fallback doesn't match the one from the <ValidationMessage> expression.

When this occurs

This is common with NotNull() / NotEmpty() rules on nested objects — the property is null because the validation fired. Using the sample models from this repo:

public record Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public Address? Address { get; set; }  // null = not yet provided
}

public record Address
{
    public string? City { get; set; }
}

// Validator
public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator()
    {
        RuleFor(x => x.Address).NotNull().WithMessage("Address is required");

        // Or with a child validator:
        RuleFor(x => x.Address!.City).NotEmpty().WithMessage("City is required")
            .When(x => x.Address is not null);
    }
}

For RuleFor(x => x.Address).NotNull(): FluentValidation produces PropertyName = "Address". This is a direct property on the root — FindField handles this correctly.

But for nested validators or child properties where the parent is null:

public record Company
{
    public string? Name { get; set; }
    public List<Employee> Employees { get; set; } = [];
}

public record Employee
{
    public string? FirstName { get; set; }
    public Address? HomeAddress { get; set; }  // null when not provided
}

// Validator with RuleForEach
RuleForEach(x => x.Employees).ChildRules(employee =>
{
    employee.RuleFor(e => e.HomeAddress).NotNull();
});

FluentValidation produces PropertyName = "Employees[0].HomeAddress". FindField navigates to CompanyEmployees[0] → tries HomeAddress — which is null. Returns null. The fallback FieldIdentifier(rootModel, "Employees[0].HomeAddress") doesn't match <ValidationMessage For="() => employee.HomeAddress"> which creates FieldIdentifier(employeeInstance, "HomeAddress").

The same issue applies to any nullable nested property in the path — the deeper the nesting, the more likely a null is encountered.

How I resolved it

Instead of relying solely on the live object instance graph, I walk the property path and stop at the last non-null parent when a null is encountered. This matches what Blazor's expression tree parsing does for <ValidationMessage For="() => parent.Property">.

The key insight: for "Employees[0].HomeAddress", we don't need HomeAddress's value to create the correct FieldIdentifier. We just need:

  • The parent object: company.Employees[0] (non-null)
  • The property name: "HomeAddress"

This produces FieldIdentifier(employeeInstance, "HomeAddress") — matching the <ValidationMessage>.

Here is the core approach (simplified):

public static FieldIdentifier ResolveFieldIdentifier(object rootModel, string propertyPath)
{
    var currentObject = rootModel;
    var remainingPath = propertyPath.AsSpan();

    while (remainingPath.Length > 0)
    {
        var dotIndex = remainingPath.IndexOf('.');
        var bracketIndex = remainingPath.IndexOf('[');

        // No more separators — this is the final field name
        if (dotIndex < 0 && bracketIndex < 0)
            return new FieldIdentifier(currentObject, remainingPath.ToString());

        int nextSep = (dotIndex >= 0 && bracketIndex >= 0)
            ? Math.Min(dotIndex, bracketIndex)
            : (dotIndex >= 0 ? dotIndex : bracketIndex);

        if (nextSep > 0)
        {
            var propertyName = remainingPath[..nextSep].ToString();
            var navigatedObject = currentObject.GetType()
                .GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)
                ?.GetValue(currentObject);

            // When null, use the CURRENT object as the model
            // with the REMAINING path as the field name.
            // This matches what <ValidationMessage For="() => parent.Property"> creates.
            if (navigatedObject is null)
                return new FieldIdentifier(currentObject, remainingPath.ToString());

            currentObject = navigatedObject;
            remainingPath = remainingPath[nextSep..];
            continue;
        }

        // Handle '.' separator and '[n]' indexers...
    }

    return new FieldIdentifier(rootModel, propertyPath);
}

Suggestion

This could be an opt-in enhancement to FindField or an alternative resolution strategy. Since reflection has performance implications in Blazor WASM, it should probably not be the default — but as a configurable fallback it would save developers from building their own workaround.

Possible API approaches (just ideas — happy to defer to your design preference):

  • A parameter on FluentValidator (e.g., ResolveNullPaths="true")
  • An enhancement to FindField that falls back to reflection-based navigation when the instance graph has nulls
  • A configurable IFieldResolver strategy

Happy to share more detail about my implementation if useful.

Environment

  • Blazilla v2.3.0
  • .NET 10.0 Blazor WASM
  • FluentValidation with nested validators and RuleForEach

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions