Summary#
The rules live on the entity, once, together with the words to say when they're broken:
entity Organization {
[Required("A name is required."), MaxLength(255)] string Name;
[Required("A slug is required."),
MaxLength(100),
Unique,
Pattern("^[a-z0-9]+(-[a-z0-9]+)*$", "Use lowercase letters, numbers and hyphens — like acme-corp.")]
string Slug;
}Everything else follows from that. The form doesn't restate the rule, so it can't drift from it.
Signature#
[Required("message")] // …the message is optional on every rule
[Pattern("regex", "message")]
[MaxLength(100, "message")]
[MinLength(3, "message")]
[Min(0, "message")] [Max(10, "message")]
catch (ValidationException ex) {
ex.Message // the sentence(s) — what to show if you only want one line
ex.Violations // one Violation per refused field: .Entity .Field .Rule .Message
}Description#
Four things happen, and you author only the last one.
1. The control obeys the model. Input(value: draft.Slug) already names the property it edits, so it configures
itself from that property's rules — required, maxlength, minlength, pattern, min, max become real
attributes on the field. The browser enforces what it can (you cannot type past a maxlength) and judges the rest, so
a bad value is genuinely invalid, not "invalid according to some code we wrote twice".
2. Your design system paints it. A field that fails its rules is in the Invalid state — an ordinary state in a
variants block, next to Hover and Focus — so you style it once and every control agrees:
variants { base { … Invalid { Border = FillDanger; } } }It holds until the user has actually touched the field, so a blank required field is not red before it's been filled in.
3. A doomed save never leaves the page. UnitOfWork.Commit() checks the pending changes against the model first. If they
can't be accepted, it raises straight away — no round-trip, and the message appears instantly.
4. A refused save says why — where it happened. Whether it was refused locally or by the server, it raises the same
ValidationException, carrying the same Violations. Catch it and put each message beside the control that produced
it.
The client is being polite, not standing guard. The server validates every write it receives, and it has the last
word — a slug someone else claims between your check and your save is refused there. The local check is deliberately
conservative: it never accuses a field the user hasn't filled in (that field may have a default only the server knows),
and it never tries to answer Unique, which is a question about other rows. When in doubt it stays quiet and lets
the write go, because wrongly blocking a good save is worse than a late "no".
The platform never writes the words. It reports which field broke which rule and hands you the sentence you declared beside it. A regex is not something to show a human, so if you word nothing, nothing is worded.
Where does the draft live?#
Everything above rests on the draft being an entity: Input(value: draft.Slug) can configure itself from
Slug's rules only because draft is an Organization, and [Required("A slug is required.")] is the only place
that sentence is written. Hold the fields as loose strings instead and both halves go: the control has no property
to read rules from, and each sentence has to be re-typed as a guard in the action — the same words in two files, free
to drift. So keep the entity-typed draft.
There is exactly one shape where it bites, and it is worth knowing before you meet it: a component that holds a
draft AND queries the same entity. A draft is pending from the moment the component mounts, and the page's own
queries read pending rows back — so the list paints a blank phantom row, and the never-filled draft rides the next
genuine save, failing on a [Required] for a row the user never opened. osy lint reports that pair as
ui-draft-field-ghosts-its-own-list (SHOULD) and names both members.
The fix that keeps this page's guarantee is not to give up the entity — it is to give the draft its own unit of
work, by putting it on a component you open with Dialog.Open(…, unitOfWork: Root). Nothing is then pending in the
list's unit of work, and the rules and their sentences stay declared exactly once:
entity Organization {
[Required("A name is required."), MaxLength(255)] string Name;
}
[Render(CSR)]
component NewOrganization() {
Organization draft = new Organization {}; // pending in THIS component's unit of work, not the page's
action Save() { Dialog.Confirm(); } // a Root dialog's Confirm is what persists it
action Cancel() { Dialog.Discard(); }
render {
Stack(gap: 2) {
Input(value: draft.Name, placeholder: "Organization name");
Button("Cancel", onPress: Cancel);
Button("Save", onPress: Save);
}
}
}
[Page("/orgs")]
[Render(CSR)]
component OrgListPage() {
live var orgs = Organization.ToList(); // no draft here, so no phantom row
action New() { Dialog.Open(NewOrganization(), unitOfWork: Root); }
render {
Stack(gap: 2) {
foreach (var o in orgs) { Text(o.Name); }
Button("New organization…", onPress: New);
}
}
}Local scalars are the other way out the linter offers, and they are the right answer when the form's fields do not
correspond to one entity at all. Reach for them knowing the cost: the declared sentence is gone, and you write
if (title == "") { formError = "A name is required."; return; } for each rule you had declared. See
[[ui-data-mutation#draft-scope]].
Examples#
The whole loop — rules on the entity, messages beside the fields that broke them. This page holds a draft and
no query over Organization, which is the shape the linter is looking for the absence of:
entity Organization {
[Required("A name is required."), MaxLength(255)] string Name;
[Required("A slug is required."),
Pattern("^[a-z0-9]+(-[a-z0-9]+)*$", "Use lowercase letters, numbers and hyphens — like acme-corp.")]
string Slug;
}
[Page("/org/new")]
[Render(CSR)]
component OrgCreatePage() {
Organization draft = new Organization {}; // the draft IS the field — nothing to fetch at mount
string nameError = "";
string slugError = "";
action Create() {
nameError = "";
slugError = "";
try {
UnitOfWork.Commit();
Navigation.Close("/org/new", true);
}
catch (ValidationException ex) {
foreach (var v in ex.Violations) {
if (v.Field == "Name") { nameError = v.Message; }
else if (v.Field == "Slug") { slugError = v.Message; }
}
}
}
render {
Stack(gap: 4) {
Input(value: draft.Name, placeholder: "Organization name");
if (nameError != "") { Text(nameError); }
Input(value: draft.Slug, placeholder: "team-slug");
if (slugError != "") { Text(slugError); }
Button("Create", onPress: Create);
}
}
}Only need one line, not per-field placement? ex.Message is the sentences, joined:
action Save() {
try { UnitOfWork.Commit(); }
catch (ValidationException ex) { error = ex.Message; }
}See also#
- creating & saving data —
UnitOfWork.Commit(), binding an input to an entity field, and where a draft belongs. - Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard —
Dialog.Open(…, unitOfWork: Root), the component whose unit of work is its own. - debounce — asking the server a question as you type (the is-this-name-taken check), which
Uniquecannot answer locally. - style props —
variants, and the states a control can be in. - Navigation —
Navigation.Save, which can be refused the same way and caught the same way.