Custom Unobtrusive Validation

Requirements are a validator, and an adapter. The Validator does the validating, and the adapter registers the validator with the validation system.

Validator

Validator is defined using $.validator.addMethod, which takes two parameters:

$.validator.addMethod(name, fn)

name is the name we give our validator, and fn is the function that contains the code that performs the validity test and returns true for validation success, false for failure. The function takes three parameters:

  • value: the value of the control the validator has been applied to
  • element: the control being validated
  • params: any parameters we want to pass to the validator

Here's an example of a RequiredIf validator. It is used to ensure that the control it is applied to has a value if another specified control either has a value, or if its value matches one of a number of expected values. For example, in an order form, a start and end date are only required if the order type control is set to "Subscription":

$.validator.addMethod('required-if', function (value, element, params) {
    if (value) return true;
    const modelPrefix = params.modelname ? params.modelname + '_' : '';
    const actualValue = $(`#${modelPrefix}${params.propertyname}`).val();
    if (actualValue && (!params.expectedvalue || params.expectedvalue.split(',').includes(actualValue))) {
        return false;
    }
    return true;
});

This one is designed to work with ASP.NET Core validation system which only validates null value properties if they have a Required attribute, or they belong to a nested "Input" model. Therefore a parameter for the model name is included so that other controls can be referenced correctly in client code.

The code checks to see if this control has a value. If it has, no further checks are necessary. If not, it checks to see if the other specified control has a value. It also checks to see if any expected values were specified, and if so, whether the actual value is one of the expected values. If so, it returns false, otherwise it returns true.

Adapter

interface RequiredIfParams {
    propertyname: string;
    modelname: string;
    expectedvalue?: string;
}

$.validator.unobtrusive.adapters.add('required-if', ['propertyname', 'modelname', 'expectedvalue'], function (options: {
    rules: Record<string, RequiredIfParams>;
    messages: Record<string, string>;
    params: RequiredIfParams;
    message: string;
}) {
    options.rules['required-if'] = {
        propertyname: options.params.propertyname,
        modelname: options.params.modelname,
        expectedvalue: options.params.expectedvalue
    };
    options.messages['required-if'] = options.message;
});

The interface defines the structure of the parameters that will be passed to the adapter. Its sole purpose is to enable strong typing of the options object which is passed to the adapter function.

The options object comprises

  • rules: Where validation rules are stored
  • messages: Where validation messages are stored
  • params: The parameters passed to the validator
  • message: The error message to display

The adapter adds a new required-if rule and specifies a required-if message to the existing rules and messages objects within unobtrusive validation.

These should be rendered as data-val-* attributes on the target form control, where data-val defines this control as participating in unobtrusive validation, and -required-if specifies the validator to apply:

<input data-val="true" 
       data-val-required-if="This field is required when OtherField is 'value'" 
       data-val-required-if-propertyname="OtherField" 
       data-val-required-if-modelname="MyModel" 
       data-val-required-if-expectedvalue="value" />

Server-side

The attributes are applied to the form control by a C# custom ValidationAttribute:

public class RequiredIfAttribute(string propertyName, string modelName, object expectedValue) : ValidationAttribute, IClientModelValidator
{
    public string PropertyName { get; } = propertyName;
    public string ModelName { get; } = modelName;
    public object ExpectedValue { get; } = expectedValue;
    
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (string.IsNullOrWhiteSpace(PropertyName))
        {
            throw new ArgumentNullException(nameof(propertyName));
        }

        var propertyToCheck = validationContext.ObjectType.GetProperty(PropertyName) ?? throw new NotSupportedException($"Can't find {PropertyName} on searched type: {validationContext.ObjectType.Name}");
        var actualValue = propertyToCheck.GetValue(validationContext.ObjectInstance);
        if (!string.IsNullOrWhiteSpace(actualValue?.ToString()) && (ExpectedValue == null || (ExpectedValue?.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(actualValue?.ToString()) ?? false))) // trigger validation
        {
            if (string.IsNullOrWhiteSpace(value?.ToString()))
            {
                return new ValidationResult(ErrorMessage ?? $"The ${validationContext.DisplayName} field is required");
            }
        }
        return ValidationResult.Success;
    }
    
    //TODO: work out why the adapterprovider doesn't seem to work, see ValidationAttributeAdapterProvider Microsoft.AspNetCore.Mvc.DataAnnotations
    public void AddValidation(ClientModelValidationContext context)
    {
        context.Attributes.TryAdd("data-val", "true");
        context.Attributes.TryAdd("data-val-required-if", ErrorMessage ?? $"This field is required");
        context.Attributes.TryAdd("data-val-required-if-propertyname", PropertyName);
        context.Attributes.TryAdd("data-val-required-if-modelname", ModelName);
        context.Attributes.TryAdd("data-val-required-if-expectedvalue", ExpectedValue?.ToString());
    }
}

The IsValid method contains the validation code which should replicate the validation check from the actual client side validator.

This attribute implements IClientModelValidator, which features a method named AddValidation where the validation attributes are specified. Alternatively, one can implement an attribute adapter, a provider for the adapter and register that with the app's service container.

Last updated: 4/4/2025 10:24:25 AM

Latest Updates

© 0 - 2025 - Mike Brind.
All rights reserved.
Contact me at Mike dot Brind at Outlook.com