Validation
UnoFeatures:
MVUX(add to<UnoFeatures>in your.csproj)
MVUX lets a state carry validation results alongside its value. The generated view model then exposes them through the standard INotifyDataErrorInfo interface, so you don't have to write a FirstNameError feed for every field.
- Validation never blocks a value. An invalid value is still set on the state, and the validation results only annotate it.
- Validation results are standard
ValidationResultinstances. MVUX does not depend onUno.Extensions.Validation, but itsIValidatorplugs in directly (see below). - A state can be validated each time it changes (
Validate), or each time a command is executed (Validation). - Validation is opt-in. When you don't use it, it costs nothing.
Validating a state
Use Validate on a state to validate its value each time it changes:
public partial record PersonModel
{
public IState<string> Name => State.Value(this, () => string.Empty)
.Validate(async (name, ct) => string.IsNullOrWhiteSpace(name)
? new[] { new ValidationResult("Name is required", new[] { nameof(Name) }) }
: Enumerable.Empty<ValidationResult>());
}
The validator:
- runs on the thread pool for the initial value and then after every data change, including changes made from the UI through two-way bindings. On WebAssembly, where there is a single thread, it runs on the UI thread, so avoid long synchronous work in it;
- is cancelled (through its
CancellationToken) when the value changes again before it completes. Results produced for a value that is no longer the current value of the state are discarded. A validator that ignores its token keeps running until it completes, so a slow validator (e.g. calling a server) should honor the token and apply its own timeout; - is not invoked when the state has no value (
None). In that case the validation results are cleared. Anullvalue is still a value, so the validator is invoked withnull; - does not put the state in error if it throws. The exception is logged and the value has no results: the results of a previous value are not kept for it, so
HasErrorsbeingfalsedoes not mean that the value has been validated.
The results of a value are kept until the next validation: they are not lost when the source of the state produces the same value again (e.g. a refresh of a State.Async), and they are not reported for another value while it is being validated.
Validate returns the same state instance and can safely be invoked each time the property getter is evaluated: the last validator wins and validators are never stacked.
Single-rule validators
For a single rule, overloads let you return only the error message, or a predicate and its message, instead of building the list of ValidationResult:
public partial record PersonModel
{
// Returns the error message, or null when the value is valid.
public IState<string> Name => State.Value(this, () => string.Empty)
.Validate(async (name, ct) => string.IsNullOrWhiteSpace(name) ? "Name is required" : null);
// Returns true when the value is valid.
public IState<int> Age => State.Value(this, () => 0)
.Validate(async (age, ct) => age is >= 0 and <= 150, "Age must be between 0 and 150");
}
The error is reported for the state itself (empty MemberNames, cf. Consuming errors in the view). Use the overload returning a list of ValidationResult to report several errors, or errors targeting members of a record.
Note
A validator lambda which only throws (no return), or which only returns null or default (e.g. an "always valid" stub), matches several overloads (CS0121). Give it an explicit return type, for example ValueTask<IEnumerable<ValidationResult>> (string name, CancellationToken ct) => throw ....
Using the IValidator service
The IValidator service of Uno.Extensions.Validation can be given directly to Validate:
public partial record PersonModel(IValidator Validator)
{
public IState<Person> Person => State.Value(this, () => new Person())
.Validate(Validator);
}
A null value has nothing to validate: the IValidator is not invoked and the value is considered as valid (the results are cleared). This differs from a delegate validator, which is invoked with null.
Note
IValidator is declared in Uno.Extensions.Core (in the Uno.Extensions.Validation namespace), so MVUX can use it without a dependency on Uno.Extensions.Validation, which registers the implementation (UseValidation).
Localizing messages
When the app uses localization, inject the IStringLocalizer in the model and pass it as the last argument of Validate, with any overload: the error messages are then resource keys, resolved through the localizer.
public partial record PersonModel(IStringLocalizer Localizer, IValidator Validator)
{
// Returns the resource key of the error message, or null when the value is valid.
public IState<string> Name => State.Value(this, () => string.Empty)
.Validate(async (name, ct) => string.IsNullOrWhiteSpace(name) ? "Validation_NameRequired" : null, Localizer);
// Returns true when the value is valid.
public IState<int> Age => State.Value(this, () => 0)
.Validate(async (age, ct) => age is >= 0 and <= 150, "Validation_AgeRange", Localizer);
// The ErrorMessage of each result is a resource key, e.g. [Required(ErrorMessage = "Validation_EmailRequired")].
public IState<Person> Person => State.Value(this, () => new Person())
.Validate(Validator, Localizer);
}
This is the only place where validation messages are localized:
Uno.Extensions.Validationreports theErrorMessageof DataAnnotations attributes and theWithMessageof FluentValidation as written, so use resource keys there (see validation messages).Messages are resolved each time the validator produces results, using the current culture. As changing the culture requires an app restart, messages already produced are not updated.
A key which is not found in the resources keeps the message as produced by the validator, so non-localized messages can be mixed with keys. So does a key for which the localizer throws: the error is logged, and the other messages are still resolved.
A resolved message is reported in a new
ValidationResult, with the sameMemberNames. If your validator produces results of a type derived fromValidationResult(e.g. with a severity), resolve their messages in the validator instead.Resolved messages are not formatted: a
{0}in a resource is displayed as is. For messages with arguments, don't pass the localizer toValidate, and use it in the validator instead:.Validate(async (person, ct) => person.Age < 18 ? [new ValidationResult(Localizer["Validation_MinAge", 18], [nameof(Person.Age)])] : [])
Setting validation results manually
Validation results are a metadata axis of the messages of the state, like the error or progress. You can set them yourself with SetValidationAsync, for instance to validate the whole form when the user clicks Save (the validation of commands does this for you):
public async ValueTask Save(CancellationToken ct)
{
var person = await Person;
var results = (await Validator.ValidateAsync(person!, null, ct)).ToList();
await Person.SetValidationAsync(results, ct);
if (results.Count is 0)
{
// Save the person
}
}
Passing null or an empty list clears the results. Setting results identical to the current ones (same ErrorMessage and MemberNames) does not raise any change. The results are kept until they are replaced, by another call or, if a validator has been set with Validate, by its results on the next change of the value. Like the results of a validator, they are not dropped when the source of the state produces a new value.
SetValidationAsync publishes the results the same way as the validator of the state and the validation of commands. You can also set the validation along with other axes of the message using UpdateMessageAsync(msg => msg.Validation(results), ct). Results set this way follow the rules of the other updates of the state: they are dropped when the source of the state produces a new value. SetValidationAsync falls back to it for a state that has not been created by the State factories.
Note
Validation results are local to the state that has them: they are not forwarded to feeds derived from it, such as Select, Combine or a Feed.Async that awaits the state. A derived FullName feed never shows the errors of the Person state.
Validating when a command is executed
Instead of validating a state each time it changes, you can validate the parameter of a command each time the command is executed, i.e. when the user submits the form. Add Validation to the command builder, between Given (or When) and Then:
public partial record PersonModel(IValidator Validator, IStringLocalizer Localizer, IPersonService Service)
{
public IState<Person> Person => State.Value(this, () => new Person());
public IAsyncCommand Submit => Command.Create(b => b
.Given(Person)
.When(person => person is not null) // CanExecute, optional
.Validation(Validator, Localizer) // Validates the person on each execution
.Then(async (person, ct) => await Service.Save(person, ct)));
}
On each execution, the command:
- validates its parameter (on a background thread, the command being
IsExecutingmeanwhile); - publishes the results on the state given as parameter (
Given), so they reach the view like the results ofValidate(see Consuming errors in the view); - if there is any error, aborts the execution: the action given to
Thenis not invoked, and the execution completes without error.
The results are not cleared when the value of the state is changed (e.g. by the user): they remain until the next execution of the command, which validates the value again (and clears them if it is valid), even if the source of the state produces a new value, for instance when a State.Async is refreshed.
Validationaccepts the same validators asValidate: anIValidator, a delegate returning a list ofValidationResult, a delegate returning the error message, or a predicate and its error. Each takes an optional localizer as last argument (see Localizing messages).- Validation errors don't change
CanExecute, so the user can always retry. - If the validator throws, the execution fails (the error goes to the error handler of the command, like an error of the action), and the previous results are kept. The error handler receives an
InvalidOperationExceptionwhich names the type of the error of the validator, but not its message, as it might contain user input (see Diagnosing validation). The execution fails the same way if the results cannot be published on the state (e.g. its context is disposed while the update is pending). - The validator receives a
CancellationToken, cancelled when the command is disposed. Honor it, and apply your own timeout to validators that might hang (e.g. a remote validation): the command remains executing until the validator completes. - An execution aborted by the validation completes like a successful one (
ExecutionCompletedwithout error). If you react to the completion of the command (e.g. to close a dialog), do it in the action given toTheninstead. - If the state is also validated by
Validate, the last validation started wins: executing the command replaces the results ofValidate, and the next change of the state replaces the results of the command. Results of a validation which completes after newer results have been published (byValidate,SetValidationAsyncor another execution) are dropped. - Configuring
Validationmultiple times on the same command replaces the previous validation (the last one wins). - With an
IValidator, anullparameter has nothing to validate and is considered as valid. - The parameter must be a state to publish the results. If it is a feed (e.g.
Feed.AsyncorFeed.Combine), or if it is provided by the view (noGiven), the execution is still aborted when the parameter is not valid, but the results cannot be displayed: only the number of errors and the members in error are logged (as a warning). The analyzer reports the warningFEED2003in that case.
Diagnosing validation
Validation never logs the messages of the results, nor the messages of the errors of validators, at a level above Debug, as they might contain user input. The relevant log categories are Uno.Extensions.Reactive.Core.StateImpl (validators of states), Uno.Extensions.Reactive.Commands.AsyncCommand (validation of commands) and Uno.Extensions.Reactive.Core.ValidationLocalizer (localization of messages):
| Level | Logged when |
|---|---|
Error |
A validator of a state throws (with the validator and the type of the error), or the results cannot be published on the state. The first time a localizer throws (with the localizer and the type of the error); the next times are logged at Debug. |
Warning |
A command is aborted by its validation, but its parameter is not a state (with the number of errors and the members in error). |
Information |
A command is aborted by its validation (with the number of errors and the members in error). |
Debug |
The details of the errors of validators (including their message). |
Consuming errors in the view
The generated view model, and the generated bindable of each record, implement INotifyDataErrorInfo. Results are routed to the object that owns the bound property according to their MemberNames:
Result MemberNames (on state Person) |
Where the error is exposed |
|---|---|
empty or ["Person"] |
GetErrors("Person") on the view model, and entity-level errors (GetErrors(null)) of the Person bindable |
["FirstName"] |
GetErrors("FirstName") on the Person bindable, i.e. the source object of {Binding Person.FirstName} |
["Address.Street"] |
GetErrors("Street") on the Person.Address bindable |
["Address"] |
GetErrors("Address") on the Person bindable, and entity-level errors of the Person.Address bindable |
["Nmae"] (not a property of Person) |
entity-level errors (GetErrors(null)) of the Person bindable, so they are not lost |
For a state of a type that has no generated bindable (such as IState<string> Name), every result is exposed as GetErrors("Name") on the view model.
HasErrors is true when the object or any of its nested bindables has errors. It is a public property, so you can bind to it, for example to show a message or to disable a button:
<TextBox Text="{Binding Person.FirstName, Mode=TwoWay}" />
<TextBlock Text="Please fix the highlighted fields"
Visibility="{Binding HasErrors}" />
Important
WinUI controls don't render INotifyDataErrorInfo errors by themselves. Display errors from your own templates or bindings, for example by binding to HasErrors or by reading GetErrors from code.
Name collisions
If a model or a record declares its own HasErrors member, the generated member of the same name hides the HasErrors property of the bindable, and the generator reports the informational diagnostic FEED1001. Validation still works through the INotifyDataErrorInfo interface, but you can't bind to HasErrors by name on that object.
If you declare a HasErrors member yourself in a hand-written partial of a generated view model, the generator doesn't add its own HasErrors to that view model and reports FEED1001 too: {Binding HasErrors} resolves to your member, while the INotifyDataErrorInfo implementation keeps working.
Current limitations
- Only states (
IState<T>) are validated. Read-only feeds and item-level errors of list states (IListState<T>) are not supported yet. Validateruns on every data change, including the initial value. There is no built-in "touched" tracking yet: use the validation of commands to validate only when the user submits the form.- Commands are not disabled automatically when there are errors. Bind to
HasErrors, or use the validation of commands to abort their execution. - The validation of commands supports a single parameter (
Given), and only commands created withCommand.Create(not the commands generated from methods).