[API Proposal]: Validation rule messages (DescriptionMessage and AsyncPendingMessage)
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
[API Proposal] Validation rule messages (DescriptionMessage and AsyncPendingMessage) - feedback revision
## Background and motivation
`ValidationAttribute` can describe a rule's failure through `ErrorMessage`, but not the rule itself. Modern form experiences often benefit from surfacing validation requirements before the user acts, such as "Username must be 4-20 characters" or "We'll verify this email is unique". Today, applications typically hardcode and duplicate that text across views, components, and frameworks, which can drift from the actual validation rule and duplicate localization and formatting work around attribute-authored data.
`ErrorMessage` already provides an attribute-authored message for validation failures, and #132764 made its formatting first-class. This proposal extends that model to the other user-facing messages that surround validation. `DescriptionMessage` allows a rule to describe itself up front, while `AsyncPendingMessage` provides the localizable status text shown while an asynchronous validation operation is running. Together, they let rule descriptions, in-progress status text, and failure messages be authored once on the attribute and consumed consistently by any UI framework.
```mermaid
flowchart LR
Before["① BEFORE validation
(user hasn't typed yet)"] -->|user types, sync passes| During["② DURING async
(DB lookup in flight ~2s)"]
During -->|result arrives| After["③ AFTER validation
(valid / invalid)"]
Before -.->|"❓ what are the rules?"| Q1["DescriptionMessage"]
During -.->|"❓ what do I show while waiting?"| Q2["AsyncPendingMessage"]
After -.->|"error text"| Q3["ErrorMessage ✅ exists today"]
style Q1 fill:#0d3b66,color:#fff
style Q2 fill:#8a6d00,color:#fff
style Q3 fill:#1b5e20,color:#fff
```
- **① Before** → **`DescriptionMessage`**: describe the rule up front so the UX can show it *before* anyone types.
- **② During** → **`AsyncPendingMessage`**: the "Checking availability…" text to show *while* the async check runs (named "Pending" because it communicates only the one pending status).
- **③ After** → `ErrorMessage` (exists, and #132764 made its template formatting first-class).
Both new messages are formatted through the same `FormatMessage` hook (#132764).
`AsyncPendingMessage` is designed to **compose with a framework's reactive validation state**, not replace it. The reactive state answers *"is validation pending?"* (the trigger); `AsyncPendingMessage` supplies *"what words to show"* (the content). For example, Blazor already exposes `EditContext.IsValidationPending(...)` / `IsValidationFaulted(...)`; those decide *whether* to show a status, while `AsyncPendingMessage` provides the attribute-authored text to show. This keeps rich reactivity (show/hide UI, gate submission) in Blazor or other UI-level frameworks while letting the rule own its wording.
**Existing workaround:** hardcode both strings in each view/component. Insufficient because it duplicates wording, drifts from the actual rules, and bypasses the attribute's formatting.
Related: builds directly on #132764 (message template formatting).
## API Proposal
```csharp
namespace System.ComponentModel.DataAnnotations;
public partial class ValidationAttribute
{
// Describes the rule; available before validation runs (lifecycle moment ①).
public string? DescriptionMessage { get; set; }
// Formats the description; routes through the existing FormatMessage(format, name) hook.
// Returns null when no description is configured.
public virtual string? FormatDescriptionMessage(string name);
}
public partial class AsyncValidationAttribute
{
// A message a UI can show while this attribute's async validation is in flight (lifecycle moment ②).
public string? AsyncPendingMessage { get; set; }
// Formats the pending message; routes through the existing FormatMessage(format, name) hook.
// Returns null when no pending message is configured.
public virtual string? FormatAsyncPendingMessage(string name);
}
```
**On localization:** Unlike `ErrorMessage`, these APIs do not replicate the legacy `*ResourceType` / `*ResourceName` pattern. `FormatMessage(format, name)` accepts a localizable template from any source (`.resx`, `IStringLocalizer`, etc.), while the attribute supplies the placeholder arguments.
Prototype: `https://github.com/dotnet/runtime/commit/da60d2350067b77c8acf0a2a4685c965a43a272c` (branch [api-proposal/validation-rule-messages](https://github.com/dotnet/runtime/compare/api-proposal/validation-rule-messages)).
## API Usage
```csharp
// 1) Author all three lifecycle messages once, on the model.
// (CoreDataAnnotations_PropertyAttribute/src/Data/Models/RegistrationModel.cs)
public sealed class RegistrationModel
{
[Required, StringLength(20, MinimumLength = 4)]
[UniqueUsername(
ErrorMessage = "Username is already taken.", // ③ after
AsyncPendingMessage = "Checking if \"{0}\" is available…", // ② during
DescriptionMessage = "Usernames must be unique.")] // ① before
public string? Username { get; set; }
}
```
```csharp
// 2) ① Show rule descriptions up front, before the user types.
foreach (var attr in TypeDescriptor.GetProperties(model)["Username"]!.Attributes.OfType())
{
if (attr.FormatDescriptionMessage("Username") is { } description)
{
Console.WriteLine(description); // "Usernames must be unique."
}
}
```
```csharp
// 3) ② AsyncPendingMessage composes with a framework's reactive pending state.
// Proposed Blazor hook (Microsoft.AspNetCore.Components.Forms): a new EditContext
// helper that finds the field's attributes and returns their formatted pending text.
public static string? GetAsyncPendingMessage(this EditContext editContext, in FieldIdentifier field)
{
foreach (var attr in GetAttributesFor(field).OfType())
{
if (attr.FormatAsyncPendingMessage(field.FieldName) is { } msg)
{
return msg;
}
}
return null;
}
```
```razor
@* 3b) The reactive API is the trigger; AsyncPendingMessage is the content.
(CoreDataAnnotations_PropertyAttribute/src/Components/Pages/Register.razor) *@
@if (EditContext.IsValidationPending(() => _model.Username))
{
@(EditContext.GetAsyncPendingMessage(FieldIdentifier.Create(() => _model.Username)) ?? "Validating…")
}
else if (EditContext.IsValidationFaulted(() => _model.Username))
{
Validation failed, please try again. @* generic, framework-level *@
}
else
{
}
```
## Alternative Designs
- **Also add `*ResourceType` / `*ResourceName` properties for parity with `ErrorMessage`.** This gives the attribute its own `.resx` lookup, matching `ErrorMessage` exactly and serving the pure static `Validator` path with no localizer. Trade-off: more surface area and a second localization path, where the framework `IStringLocalizer` route (which can itself be `.resx`-backed) already covers resource-based localization. The current prototype includes this pair.
- **Constructor overloads vs. settable properties:** settable properties, matching the established `ErrorMessage` shape.
## Open Questions
- Confirm the **faulted** state stays a framework-level *generic* message (no per-attribute fault string). Tentative answer: yes.
- Confirm whether the success-after case stays a framework-level *generic* affordance (e.g. a wordless green check) or gains a per-attribute, rule-specific `SuccessMessage` that authors its own wording like `ErrorMessage` (formatted via `FormatMessage`). Tentative answer: rule-specific, since success text is often data-bearing (for example, `"{0}" is available`).
> [!NOTE]
> "Faulted" means an exception was thrown while *attempting* to validate (for example, the database was unreachable), which is distinct from a validation *error* (a rule returning an invalid result). A faulted async validation never produced a valid/invalid verdict; it failed to run at all. Because faulting is a transient, run-level failure rather than a rule outcome, it stays a framework/reactive concern surfaced with a generic message (e.g. Blazor's `IsValidationFaulted`), with no per-attribute fault string. In short: faulted (could not validate) is not the same as invalid (validated, and the rule failed).
## Risks
Additive; existing validation behavior and the `ValidationResult` shape are unchanged.
Minor source-breaking risk: a derived attribute that already declares a member named `DescriptionMessage`, `AsyncPendingMessage`, `FormatDescriptionMessage`, or `FormatAsyncPendingMessage` would now shadow or collide with the new base members; expected to be rare, and a compile-time (not binary) break.
> [!NOTE]
> This content was generated with the assistance of GitHub Copilot.
Contributor guide
Assessment
This issue has not been assessed yet.