Validation
Udodi validates form controls through the @validate directive.
Validators are ordinary component methods. They run according to a field's @trigger configuration, write their results to the form controller's reactive errors object, and participate automatically in form submission.
This guide covers:
- Declaring validators with
@validate - Validation triggers with
@trigger - Validator signatures and return values
- Validation context
- Validation errors
- Multiple validators on one field
- Values passed to validators
- Handling missing or invalid validators
For form-level sequential and parallel execution, see Sequential and Parallel Validation.
For asynchronous validators, AbortSignal, cancellation, and race-condition safety, see Async Validation.
Declaring Validators
@validate accepts one or more validator names separated by whitespace:
<input
name="email"
@validate="required"
/>
<input
name="email"
@validate="required email"
/>
<input
name="password"
@validate="required min:8"
/>
<input
name="age"
@validate="between:18:120"
/>Each validator name must resolve to a callable function on the component context, typically a method declared under methods.
Validator names are single identifiers. Nested paths and arbitrary expressions are not supported:
<!-- Valid -->
<input @validate="required email" />
<!-- Invalid -->
<input @validate="validators.required" />
<!-- Invalid -->
<input @validate="user.validators.required" />Validator Arguments
Arguments can be supplied after the validator name using ::
<input @validate="min:8" />
<input @validate="between:18:120" />Conceptually, these become:
@validate="required" → required(value, ctx)
@validate="min:8" → min(value, 8, ctx)
@validate="between:18:120" → between(value, 18, 120, ctx)Arguments are evaluated according to Udodi's directive argument rules before being passed to the validator.
A quoted string literal is not a valid validator declaration:
<!-- Invalid -->
<input @validate="'required'" />Use the validator identifier directly:
<!-- Valid -->
<input @validate="required" />Validation Triggers
Validation does not run when a field is registered or when the component mounts.
It runs when one of the field's configured validation triggers occurs.
| Trigger | When validation runs |
|---|---|
live (default) | On input / change, depending on the control type |
lazy | On blur |
submit | When the parent form is submitted through @submit |
Live Validation
live is the default:
<input
name="email"
@validate="required email"
/>Text-like controls are validated as their value changes. The checkbox, radio, and select controls use their appropriate change event.
Lazy Validation
Use lazy when validation should wait until the user leaves the field:
<input
name="email"
@validate="required email"
@trigger="lazy"
/>Submit Validation
Use submit when a field should only be validated as part of form submission:
<input
name="email"
@validate="required email"
@trigger="submit"
/>The field must belong to a <form> registered with @form. Otherwise, the runtime warns and does not register the submit-linked validation.
Combining Triggers
Multiple triggers can be specified:
<input
name="email"
@validate="required email"
@trigger="live lazy submit"
/>The field then validates on both input/change, blur, and submit.
If @trigger is omitted, live is used.
Unknown trigger tokens causes the runtime to throw an error.
Triggers and Validation Modes
Triggers determine when a field is validated.
They do not determine how validators for different fields are scheduled during full-form validation.
For example:
<form @form="signup parallel">
<input
name="email"
@validate="required email"
@trigger="live"
/>
<input
name="password"
@validate="required min:8"
@trigger="submit"
/>
</form>The field triggers control interactive validation, while the form's validation mode controls full-form validation such as validation initiated by @submit.
See Sequential and Parallel Validation.
Writing Validators
Validators are component methods, usually declared under methods:
const ContactForm = createComponent({
name: "ContactForm",
methods: {
required(value) {
return value?.trim()
? true
: "This field is required";
},
},
});Signature
A validator receives:
validator(value, ...args, validationContext)The arguments are:
| Argument | Meaning |
|---|---|
value | Current value of the form control. |
...args | Values supplied after : in the validator declaration. |
validationContext | Metadata describing the current validation cycle. |
For example:
<input @validate="min:8" />corresponds conceptually to:
min(value, 8, ctx);Validator Return Values
A validator communicates its result through its return value:
| Return value | Meaning |
|---|---|
true | Validation succeeded. |
| Non-empty string | Validation failed; the string becomes the error message. |
| Anything else | Validation failed with the runtime's default error message. |
For example:
required(value) {
return value?.trim()
? true
: "This field is required";
}A successful validator returns:
return true;A failed validator returns an error message:
return "Please enter a valid email";An empty string is not a successful validation result. Validators should explicitly return true for success.
First Failure Wins
Validators declared on the same field always execute sequentially.
Validation stops at the first failing validator:
<input
name="password"
@validate="required min:8 strongPassword"
/>The runtime evaluates:
required
│
├── failure → stop
│
└── success
│
▼
min:8
│
├── failure → stop
│
└── success
│
▼
strongPasswordForm-level parallel validation does not change this behavior. Parallel mode controls validation across different fields, not the order of validators attached to one field.
Example Validators
methods: {
required(value) {
return value?.trim()
? true
: "This field is required";
},
email(value) {
if (!value) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? true
: "Please enter a valid email";
},
min(value, limit) {
return !value || value.length >= Number(limit)
? true
: `Must be at least ${limit} characters`;
},
between(value, min, max) {
const number = Number(value);
if (Number.isNaN(number)) {
return "Must be a number";
}
if (
number < Number(min) ||
number > Number(max)
) {
return `Must be between ${min} and ${max}`;
}
return true;
},
}These validators can then be composed declaratively:
<input
name="email"
@validate="required email"
/>
<input
name="password"
@validate="required min:8"
/>
<input
name="age"
@validate="required between:18:120"
/>Udodi does not provide built-in required, email, min, or similar validators. They are application-defined component methods.
Those methods can perform the checks themselves or delegate to a schema validation library of your choice.
Validation Context
Every validator receives a validation context as its final argument:
{
trigger,
element,
event,
signal,
}trigger
Identifies why the validation cycle was started:
ctx.triggerPossible values are:
"live""lazy""submit"
A validator can use this when its behavior needs to differ between interactive and submit validation:
required(value, ctx) {
if (!value?.trim()) {
if (ctx.trigger === "submit") {
return "This field is required";
}
return true;
}
return true;
}element
The DOM control currently being validated:
ctx.elementThis provides access to the underlying form control when a validator needs DOM-specific information.
event
The DOM event that initiated validation, when one exists:
ctx.eventFor example, live validation may receive the originating input or change event.
Submit-triggered validation can receive the submit event associated with the form submission.
signal
An AbortSignal associated with the current validation cycle:
ctx.signalAsynchronous validators should pass this signal to cancellable operations such as fetch:
async uniqueEmail(value, ctx) {
const response = await fetch(
"/api/check-email?q=" + encodeURIComponent(value),
{
signal: ctx.signal,
},
);
const data = await response.json();
return data.available
? true
: "Email is already registered";
}See Async Validation for the complete asynchronous validation model.
Errors on the Form Controller
When a named field fails validation, its error is stored on the form controller:
ud.forms.login.errors.email;The value is:
"" → no validation error
"This field is required" → validation failedFor example:
<input
name="email"
@validate="required email"
/>
<span @text="ud.forms.login.errors.email"></span>The error object is reactive, so changes are reflected automatically in templates.
Error Requirements
A field needs a non-empty name for a field-specific error entry to exist:
<input
name="email"
@validate="required"
/>creates:
ud.forms.login.errors.email;A validated control without a name can still run validation, but its error cannot be represented by a named entry in errors.
Form Validity
The form controller's valid state is derived from its errors object.
A form is valid only when every error entry is an empty string:
controller.valid === true;When validation succeeds, the field's previous error is cleared back to:
controller.errors.email === "";Errors can also be managed explicitly:
controller.setError(
"email",
"This email address is already registered",
);
controller.resetError("email");These methods change the error state without executing validators and are useful for server-side validation results.
See Form Controllers.
Multiple Validators on One Field
A field can declare multiple validators:
<input
name="password"
@validate="required min:8 strongPassword"
/>They always execute in declaration order:
requiredruns.- If it succeeds,
min:8runs. - If that succeeds,
strongPasswordruns. - The first failure stops the validation cycle.
For example, if required fails:
required → failure
min:8 → skipped
strongPassword → skippedIf required succeeds but min:8 fails:
required → success
min:8 → failure
strongPassword → skippedThis ordering is independent of the form's validation mode.
Single Field vs Full Form
The distinction is important:
Within one field:
validator 1 → validator 2 → validator 3is always sequential.
Across multiple fields:
email
password
usernamecan be validated sequentially or in parallel depending on the form's validation mode.
See Sequential and Parallel Validation.
Values Passed to Validators
Validators receive the current value of the DOM control.
| Control | Value passed to validator |
|---|---|
| Text-like controls | element.value |
<textarea> | element.value |
<select> | element.value |
| Checkbox | element.checked |
| Radio | element.value when checked |
For radio controls, unchecked controls are skipped rather than being validated with a null value.
The same value semantics are used by the field system. See Working with Fields.
Validation and @bind
Validation reads the DOM control, not component state directly.
For example:
<input
name="email"
@bind="email"
@validate="required email"
/>@bind keeps component state synchronized with the DOM, while @validate reads the current DOM value when validation runs.
This separation allows validation to work even when @bind is not present:
<input
name="email"
@validate="required email"
/>In that case, the application can use FormData during submission or access the control through the form controller.
Missing or Invalid Validators
Every validator name must resolve to a callable function on the component context.
If it does not:
- The runtime logs a warning.
- The validator is treated as invalid.
- The validation cycle receives the runtime's default error message.
For example:
<input
name="email"
@validate="doesNotExist"
/>produces a warning because doesNotExist cannot be resolved to a validator function.
The same applies to unsupported nested paths:
<input
name="email"
@validate="validators.required"
/>and non-callable values.
Keep validator declarations to top-level component methods:
methods: {
required(value) {
return value?.trim()
? true
: "Required";
},
}<input
name="email"
@validate="required"
/>Minimal Example
import { createComponent, html, render } from "udodi";
const ContactForm = createComponent({
name: "ContactForm",
state() {
return {
email: "",
message: "",
};
},
methods: {
required(value) {
return value?.trim()
? true
: "Required";
},
email(value) {
if (!value) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? true
: "Enter a valid email";
},
min(value, length) {
return !value ||
value.length >= Number(length)
? true
: `At least ${length} characters`;
},
},
template: html`
<form @form="contact">
<label>
Email
<input
name="email"
type="email"
@bind="email"
@validate="required email"
@trigger="lazy"
/>
<span @text="ud.forms.contact.errors.email"></span>
</label>
<label>
Message
<textarea
name="message"
@bind="message"
@validate="required min:10"
></textarea>
<span @text="ud.forms.contact.errors.message"></span>
</label>
<p @show="ud.forms.contact.validating">
Validating...
</p>
<p @show="ud.forms.contact.valid">
All fields look good
</p>
</form>
`,
});
render(ContactForm(), "#app");In this example:
emailusesrequiredandemailvalidators.- Email validation runs on blur because of
@trigger="lazy". messageusesrequiredandmin:10.- Validators execute in declaration order.
- The first failing validator determines the field's error.
- Errors are exposed reactively through
ud.forms.contact.errors. validatingandvalidcan be consumed directly from the reactive form controller.