Use with Blazor forms
dotnet add package AdCodicem.ValueObjects.Blazor
@using AdCodicem.ValueObjects.Blazor @* in _Imports.razor *@
Razor finds a component through a Razor @using, not a C# global using: without it, the compiler reports RZ10012,
"Found markup element with unexpected name 'InputValueObject'", and fails on @bind-Value with RZ9991.
<EditForm Model="Order" OnValidSubmit="Save">
<DataAnnotationsValidator />
<InputValueObject @bind-Value="Order.Iban" DisplayName="IBAN" />
<ValidationMessage For="() => Order.Iban" />
<InputValueObject @bind-Value="Order.Quantity" />
<InputValueObject @bind-Value="Order.Reference" /> @* Iban?, Reference<PurchaseOrder>, any value object *@
<ValidationSummary />
</EditForm>
builder.Services.AddValueObjectInputs(options => options.UseResource<ValidationMessages>()); // optional, messages by code
The package references Microsoft.AspNetCore.Components.Web, not the ASP.NET Core shared framework, so a Blazor
WebAssembly application installs it as a Blazor Server or a Blazor Web App one does. In a server application, the shared
framework's copy of the Components assemblies is the one loaded.
Without the package
A value object reaches a Blazor form through @bind, which reads text with BindConverter, and through it the
[TypeConverter] the generator writes:
- Refused text is dropped without a message. The converter throws a
ValueObjectExceptioncarrying the rule's code; the binder swallows it, never calls the setter, and the field goes back to the last valid value. - Empty text sets the default instance. The binder calls the setter with
default: anIbanbecomes the instance nobody filled in, and aQuantitybounded 1..100 becomes 0, which the input then shows, a value its own rules refuse. - The culture drops text silently.
13,5typed into a@bindwithout@bind:culturedisappears the same way.
None of the built-in inputs binds a value object either. InputText is for string alone, InputNumber<Quantity>
fails in its type initializer and InputDate<DueDate> throws "Unsupported … type param". InputSelect and
InputRadioGroup parse an option through BindConverter and catch only InvalidOperationException, so a refused option
throws the ValueObjectException out of the change event, which is fatal to the circuit of a Blazor Server application
(below).
The input in a form
InputValueObject<TValue> takes a value object or its nullable type, which Razor infers from @bind-Value. It renders a
text <input>, as InputText does, with the classes modified, valid and invalid, aria-invalid, the field's
name in a static form and the attributes it is given, and reads its text through the value object's TryParse, the
overload that reports the rule:
- Accepted text binds the normalized instance, which the input then shows:
fr76 3000 6000 0112 3456 7890 189becomesFR7630006000011234567890189in the field,po-1042becomesPO-1042. - Refused text leaves the model as it was and stays in the field, marked
modified invalidwitharia-invalid="true". The rule's message is added to the form's validation messages, soValidationMessageandValidationSummaryshow it,EditContext.Validate()returnsfalseandOnValidSubmitis not called until the text is fixed. The message is the rule's own, "The value must be at least 15 characters long.", "The value must be less than or equal to 100.", "The value is not one of the accepted values." for a closed set, and never quotes the text typed. A parser written by hand that refuses without a reason is answered "The text could not be parsed as Code.", and one that gives an empty message "The field Code is invalid.", as[ParsesAs]answers them.
Text typed into a form comes from outside, so it always goes through the rules: there is no trusted option. A refusal
is a validation message, never an exception.
Nullable members and empty text
- On a nullable value object,
Iban?, empty text bindsnull, with no message:InputBasedoes it before any parsing. - On a value object that cannot be null, empty text is parsed as any other text. A type that accepts it binds what it
creates:
[ValueObject<string>(AllowEmpty = true)]binds the empty instance, not the default. - A type that refuses it is answered with
value_object.requiredand the message[Required]gives, "The {0} field is required.",{0}being the input'sDisplayName, or else the name of the field. AGuidvalue object would otherwise say "The text is not a valid System.Guid." of a field the person emptied. The model keeps its value, never the default.
Which text an instance shows
The input writes the text its type reads back:
- A value object without a formatting hook shows its own text, written in the round-trip form of its underlying type:
2026-10-14for aDateOnly,10:30:15.2500000for aTimeOnly,2026-10-14T10:30:15.2500000Zfor aDateTime, its kind included,2026-10-14T10:30:15.2500000+02:00for aDateTimeOffset,1.01:01:01.0010000for aTimeSpan, and a number in the culture of the input. ADateOnlyvalue object therefore suitstype="date", whose value is that form; a number suitstype="number"only withCulture="CultureInfo.InvariantCulture", the form a browser gives it. - A value object whose formatting hook writes other text shows its bare underlying value, in that same form: a
temperature that prints
21 °Cshows21, since its parser refuses its ownToString(). - An instance equal to the default that its rules refuse, a field nobody filled in, shows as empty text: an unset
Quantitybounded 1..100 no longer shows0. An instance equal to the default that they accept, a count from 0 or 0 °C, shows as its value, so that a static form posts it back.
The culture
The text is read and written in the input's Culture. When it is not set:
- in the current culture where the component is interactive, as
@bindandInputSelectread text, and while an interactive component prerenders, so that the prerendered page and the interactive one show12,5alike infr-FR; - in the invariant culture under static rendering, which is the culture the framework reads a posted form in: a
static
fr-FRpage writes12.5, and its untouched post reads back.
A date, of a value object over DateOnly, DateTime or DateTimeOffset, is read in the Gregorian calendar its text is
written in, whatever the culture's own. In the cultures whose calendar is another, the Thai Buddhist one of th-TH, the
Persian one of fa-IR or the Um al-Qura one of ar-SA, the input reads with a copy of the culture set to its Gregorian
calendar: the culture's patterns and month names stay, and a year typed is a Gregorian one. 1990-10-14, the text the
input shows, and 14/10/1990 in th-TH read as 14 October 1990; 14/10/2533, the Buddhist year, reads as the year 2533,
which a type bounded to plausible birth dates refuses. Read in the culture's own calendar, the text the input writes
would come back 543 years earlier in th-TH, 621 years later in fa-IR, and refused in ar-SA.
The code
An EditContext holds a refusal as a message alone. The input keeps the code of the rule beside it, on the form, from the
refusal until the next text it reads, an emptied nullable field included, or until it is disposed:
if (editContext.TryGetValueObjectErrorCode(FieldIdentifier.Create(() => Order.Quantity), out var code))
{
// value_object.out_of_range, or the code of the application's own rule
}
This is the field's equivalent of ValueObjectErrors.ErrorCodeKey, for a test, a telemetry hook or a component that
styles a field by rule rather than by its English message. An input outside an EditForm reads and binds its text the
same way, and has no form to keep a code on.
Messages in the application's language
builder.Services.AddValueObjectInputs(options => options.UseResource<ValidationMessages>());
The message of a refusal is then looked up in the resources of ValidationMessages by the code of the rule, under the
keys of the errors reference,
ValueObject.value_object.too_short first, with the input's DisplayName, or else its field's name, as {0} and the
arguments the type's rules supply from {1}, in the current UI culture, and formatted in the current culture: the culture
a Blazor Server circuit or a Blazor WebAssembly application runs in. "« IBAN » : au moins 15 caractères." and
"« Quantity » doit être compris entre 1 et 100." come from a fr resource; a code the resource has no key for keeps the
English message. AddValueObjectInputs registers AddLocalization(), keeping the localizer factory and the
ResourcesPath the application registered first. Options configured by hand, without the localization services, fail
the first refusal with an InvalidOperationException that names builder.Services.AddLocalization(), thrown from the
change event.
A Blazor WebAssembly application loads the satellite assemblies of the culture it starts in.
A field nobody touched
The input checks the text it is given; a field nobody touched holds its default instance, which no text refused.
[NotDefault] under DataAnnotationsValidator is the guard
(DataAnnotations):
public sealed class Order
{
[NotDefault]
public Iban Account { get; set; }
}
An untouched form is then refused on submit with "The Account field is required.". While the field shows refused text
and the model still holds the default, the field carries both messages, the input's and [NotDefault]'s:
DataAnnotations names the field by [Display], or else by its member, and the input by its DisplayName.
Static rendering
A page that sets no render mode is rendered statically, and its form is posted back. The framework maps the post into the
[SupplyParameterFromForm] model, through IParsable<T> and in the invariant culture, before any component runs, and
nothing public lets a package change that mapping. So in a static form:
- A refused value is answered with the framework's message, "The value 'XX' is not valid for 'Account'.", which quotes the text and carries neither the rule's message nor its code, and the member it refused holds its default.
- An optional value object left empty is refused, "The value '' is not valid for 'Optional'.": the framework maps
empty text to
nullonly for primitive types,DateOnly,TimeOnlyandDateTimeOffset, on purpose (dotnet/aspnetcore#52499 and #60498). - Accepted text binds, normalized, and the invariant text the input writes reads back untouched: a date, a time, a
duration and a number in a
fr-FRpage alike.
For a static form whose refusals should carry the rule's message, keep the text and hold it to the rules with
[ParsesAs], which DataAnnotationsValidator checks on submit:
public sealed class OrderForm
{
[ParsesAs(typeof(Iban))]
public string? Account { get; set; }
}
<InputText @bind-Value="Model.Account" />
<ValidationMessage For="() => Model.Account" /> @* The value must be at least 15 characters long. *@
The model then holds text, which the application converts once the form is valid, and DataAnnotationsValidator keeps
the message alone, without the code.
InputSelect and InputRadioGroup
InputSelect<CountryCode> and InputRadioGroup bind an option a value object accepts, and throw the
ValueObjectException of one it refuses out of the change event, which ends a Blazor Server circuit. Render the options
from the known values the type declares, so that none is refused:
<InputSelect @bind-Value="Order.Country">
@foreach (var known in CountryCode.Schema.KnownValues)
{
<option value="@known">@known</option>
}
</InputSelect>
The attributes of the rules
<InputValueObject @bind-Value="Order.Iban" SchemaAttributes="true" />
SchemaAttributes="true" writes the maxlength and minlength of the type's rules, which a browser counts in UTF-16
code units as .NET does, and its pattern, when a browser reads it as .NET does:
maxlength="34" minlength="15" pattern="^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$". An attribute the input is given wins:
maxlength="40" beside SchemaAttributes keeps 40.
The attributes are off by default, because a browser checks the text as it is typed, before the normalizer runs. A
Maltese IBAN, 31 characters, typed in its printed groups of four is 38, which a browser may stop at maxlength="34";
fr76 3000 … does not match the canonical pattern, and a static EditForm, rendered without novalidate, would be
blocked by the browser's own validation before any .NET code reads it; an interactive form would show the browser's
popups, in its language, beside the input's messages. Reserve them for a type whose normalizer leaves typed text alone.
A browser compiles pattern as ^(?:…)$ with the v flag, and drops a pattern it cannot compile. The input writes a
pattern only when the browser accepts every text the .NET pattern accepts:
- each alternative is anchored at both ends,
^…$, as the browser anchors it; \d,\w,\sand\p{…}are written as the characters of the Basic Multilingual Plane .NET matches them with, so that^[A-Z]{3}-\d{3,10}$acceptsABC-١٢٣in the browser as in .NET, where the raw\drefuses it;- a lazy quantifier is written greedy and a group without capturing, and
-is escaped in a class, where the raw^[A-Z0-9-]+$is a syntax error under thevflag; - no pattern is written for
., a negated class or escape, a word boundary, a class subtraction, a surrogate, a named block or the categoriesCandCs, which the engines read apart, nor for a pattern the shared reader refuses, a lookaround among them.
Generic and hand-written value objects
The input finds the value object in the registry, where a generated one is registered when its assembly loads. A value
object written by hand, and a construction of a generic one, Reference<PurchaseOrder>, are described by reflection the
first time an input binds them, where the runtime supports dynamic code. Where it does not, they are registered by hand
before the input renders, or its first render fails with an InvalidOperationException naming the registration:
ValueObjectRegistry.Register<HandWrittenCode, string>(HandWrittenCode.Schema);
ValueObjectRegistry.Register<Reference<PurchaseOrder>, string>(static () => new Reference<PurchaseOrder>.ValueJsonConverter());
Native AOT and trimming
The package is AOT-compatible: each input closes its text adapters over the value object through the descriptor's
visitor, never with MakeGenericType. A trimmed Blazor WebAssembly publish, checked when the package was designed
and not in CI, reported warnings from Microsoft.AspNetCore.Components and Microsoft.JSInterop alone, none from these
assemblies. Native AOT is no Blazor host, but CI renders a form of every registered value object through HtmlRenderer in a native binary, whose output
matches the JIT's; Microsoft.AspNetCore.Components warns for itself there, IL2104 and IL3053, once a component
renders.
Testing a form with bUnit
The input reads RendererInfo, so bUnit asks for it, or throws MissingRendererInfoException. Register the services
first, then set it. An interactive form reports an interactive renderer:
using var context = new BunitContext();
context.Services.AddValueObjectInputs(options => options.UseResource<ValidationMessages>());
context.SetRendererInfo(new RendererInfo("Server", isInteractive: true));
var form = context.Render<OrderForm>();
form.Find("input").Change("XX");
A component an interactive page prerenders reports a static renderer, with its render mode assigned, which is what makes the input write in the current culture there:
context.SetRendererInfo(new RendererInfo("Static", isInteractive: false));
var form = context.Render<OrderForm>(parameters => parameters.SetAssignedRenderMode(RenderMode.InteractiveServer));
A static renderer with no render mode assigned stands for a statically rendered page, read and written in the invariant culture.
.NET 11
The input built against .NET 10 works on the .NET 11 release candidate, whose InputBase moved into
Microsoft.AspNetCore.Components.Web and is forwarded. Two differences show: ValidationMessage writes the
data-valmsg-for and data-valmsg-replace attributes of the client validation of static forms, so read messages from
the EditContext rather than the markup, and InputText writes an id of its own, which this input does not: give it
one explicitly. EditContext.Validate() is obsolete there, for ValidateAsync(), which refuses a form holding refused
text alike.
Not covered
- A select of known values.
InputValueObjectSelect<TValue>, rendering the options of a closed set from its known values, follows on demand. - Component libraries. The inputs of MudBlazor, Radzen and Fluent UI were not checked; those that read text through
BindConverterpresumably bind a value object as@binddoes. - A browser. What a browser makes of
maxlengthandpattern, a circuit ended byInputSelect, and the culture a circuit or a WebAssembly application gives the messages follow the HTML standard and Microsoft's documentation; no browser ran them.