dotnet / dotnet/runtime

[API Proposal]: ValidationAttribute.DescriptionMessage

Open
#133,433 2 comments 0 reactions 1 assignee Claimed by @ViveliDuCh View on GitHub
api-needs-work area-System.ComponentModel.DataAnnotations
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

## 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 adds `DescriptionMessage`, which lets a rule describe itself up front, authored once on the attribute and consumed consistently by any UI framework.

A (potentially async) validation rule passes through several user-facing moments. `ErrorMessage` covers the failure moment today; this proposal adds the "before" moment. The "during" moment is a separate, related message (`AsyncPendingMessage`) that is explicitly deferred to .NET 12.

```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
(this proposal)"]
During -.->|"❓ what do I show while waiting?"| Q2["AsyncPendingMessage
(deferred to .NET 12)"]
After -.->|"error text (invalid)"| Q3["ErrorMessage ✅ exists today"]
style Q1 fill:#0d3b66,color:#fff
style Q2 fill:#555,color:#fff,stroke-dasharray:5 5
style Q3 fill:#1b5e20,color:#fff
```

- **① Before** → **`DescriptionMessage`** (this proposal): describe the rule up front so the UX can show it *before* anyone types.
- **② During** → **`AsyncPendingMessage`** (deferred to .NET 12): the "Checking availability…" text to show *while* an async check runs.
- **③ After (invalid)** → `ErrorMessage` (exists, and #132764 made its template formatting first-class).

**Existing workaround:** hardcode the description in each view/component, or hand a "before"-style template to `FormatMessage` at each call site. `FormatMessage` (from #132764) already lets an attribute fill its own arguments, so the *numbers* stop drifting, but it sources arguments, not the template: the wording still has no home on the rule, and there is no uniform way to ask any attribute "what is your description?". So the template is re-authored per surface (and drifts there), and generic readers cannot discover it.

Because a description is just text, any consumer can render it without a reactive UI framework. This unblocks application-level UI beyond Blazor, for example a command-line tool surfacing a rule's constraints in `--help` output, with no platform integration required. That platform-agnostic reach is a key reason `DescriptionMessage` is valuable on its own, independent of the deferred async "pending" message (which does depend on a reactive UI).

```mermaid
flowchart TB
subgraph NeedsUI["Needs a UI framework to render"]
P["'Checking…' pending spinner
(reactive, mid-flight)"]
end
subgraph JustText["Just a string, any reader works"]
D["DescriptionMessage
'Age must be between 18 and 120.'"]
end
P --> Blazor["Blazor / MVC
(reactive components)"]
D --> Console["Console (--help)"]
D --> Blazor2["Blazor"]
D --> Log["Logs / REST / tests"]
style NeedsUI fill:#3a2b12,color:#fff
style JustText fill:#12303a,color:#fff
```

_Unlike UI-specific features that require framework integration and rendering support, a plain text message is universally consumable anywhere text can be displayed: a console app, a log line, a `--help` screen, a REST response, a unit test._

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 for display; routes through the existing FormatMessage(format, name) hook.
// Returns null when no description is configured.
public virtual string? FormatDescriptionMessage(string name);
}
```

`DescriptionMessage` is a localizable template; `FormatDescriptionMessage(name)` returns the localized string, formatting placeholder arguments via the `FormatMessage` hook from #132764. Localization follows the modern path: a framework supplies a localized template to `FormatMessage`. Because an `IStringLocalizer` can be resource-backed (the default `ResourceManagerStringLocalizer` reads `.resx`), this covers the same `.resx` scenario the legacy `ErrorMessage` `*ResourceType` / `*ResourceName` pair addressed, without adding that attribute-owned lookup here.

Prototype: `https://github.com/dotnet/runtime/commit/52f26bbca5203193656faff61ff0c509efb20264` (branch `api-proposal/validation-description-message`). Note: the current prototype also carries the `*ResourceType` / `*ResourceName` pair (see Alternative Designs); the proposed shape above omits it.

## API Usage

```csharp
// 1) Author the rule + its description once, on the model.
public sealed class RegistrationModel
{
[Required]
[StringLength(20, MinimumLength = 4)]
[UniqueUsername(
DescriptionMessage = "Usernames must be unique.", // ① before
ErrorMessage = "Username is already taken.")] // ③ after (invalid)
public string? Username { get; set; }
}
```

```csharp
// 2) A UI reads it up front, before the user types (framework-agnostic shape).
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) Built-in attributes can supply a default description with their own placeholder args,
// reusing the FormatMessage override they already provide (from #132764).
var range = new RangeAttribute(18, 120) { DescriptionMessage = "Age must be between {1} and {2}." };
string text = range.FormatDescriptionMessage("Age")!; // "Age must be between 18 and 120."
```

## Sample Consumers

Two consumers at opposite ends of the UI spectrum, both reading the same `DescriptionMessage` authored once on the rule. One is a plain console with no reactive machinery; the other is a reactive Blazor form. Neither surface needs the attribute to know anything about the other.

### 1. System.CommandLine (no reactive UI): constraints in `--help`

CLI options carry a description that prints in `--help`, but don't derive it from DataAnnotations, so authors retype "must be between 18 and 120" next to the validator.

```csharp
// src/command-line-api/src/System.CommandLine/Symbol.cs:23 (Option inherits Symbol)
public string? Description { get; set; }

// With DescriptionMessage: sourced from [Range], self-updating:
ageOption.Description = ageAttr.FormatDescriptionMessage("age");
```

This is the "just text, no platform integration" case: it proves the value must live on the attribute, reachable by a non-UI consumer.

### 2. Blazor forms (reactive UI): hint / placeholder before typing

The `DataAnnotationsValidator` bridge already pumps `ErrorMessage` into a `ValidationMessageStore` **after** a failure. There is no channel for pre-validation text, so apps hardcode field hints. With `DescriptionMessage`, the same bridge can expose the field's description as placeholder/hint text, authored on the rule, not the view.

```razor
@* FieldHints.razor: rule descriptions shown up front, before any input or failure. *@
@foreach (var attr in GetValidationAttributes(_field))
{
@* One line per rule that chose to describe itself. *@
if (attr.FormatDescriptionMessage(_field.FieldName) is { } hint)
{

  • @hint
  • @* [Range] produces "Age must be between 18 and 120." *@
    }
    }
    ```

    ```csharp
    // Placeholder is a single member-level blurb; hints are per rule.
    string? placeholder = displayAttr?.GetDescription(); // [Display(Description)] : one per member
    IEnumerable hints = GetValidationAttributes(field)
    .Select(a => a.FormatDescriptionMessage(field.FieldName))
    .OfType(); // DescriptionMessage : one per rule
    ```

    > [!NOTE]
    > `[Display(Description="...")]` is a **member-level** description: one field, one blurb (for example "your date of birth"). `DescriptionMessage` is **per rule**: a field decorated with `[Required]`, `[Range]`, and a custom check can surface a distinct line for each rule that opts in. The two compose: the display description says *what the field is*, the rule descriptions say *what must be true*.

    ## 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.

    ## 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` or `FormatDescriptionMessage` would now shadow or collide with the new base members; expected to be rare, and a compile-time (not binary) break.

    > [!NOTE]
    > This API proposal draft was generated with GitHub Copilot.

    Contributor guide

    Open the contributing guide

    Assessment

    This issue has not been assessed yet.

    Get new issues in your inbox

    A short digest of beginner-friendly GitHub issues.