Outcome Recipes
Outcome-focused validation scenarios that show how to wire Aurelia's validation controller, rules, and presenters for real forms.
These recipes start with the result you want and walk through the minimal steps to get there with @aurelia/validation + @aurelia/validation-html.
1. Instant inline validation feedback
Goal: Highlight invalid inputs as soon as a user blurs a field, with errors listed next to each control.
Steps
Register the HTML adapter as usual:
Aurelia.register(ValidationHtmlConfiguration);In your form component, define rules for the model:
import { IValidationRules } from '@aurelia/validation'; import { IValidationController } from '@aurelia/validation-html'; import { newInstanceForScope, resolve } from '@aurelia/kernel'; export class SignupForm { person = { name: '', email: '' }; controller = resolve(newInstanceForScope(IValidationController)); constructor(private rules = resolve(IValidationRules)) { this.rules .on(this.person) .ensure('name').required() .ensure('email').required().email(); } }Use the
validatebinding behavior with a trigger (changeOrBlurfires on blur and subsequent edits) and capture errors with thevalidation-errorsattribute:<form submit.trigger="controller.validate()"> <div validation-errors.from-view="nameErrors"> <label> Name <input value.bind="person.name & validate:changeOrBlur"> </label> <ul if.bind="nameErrors?.length"> <li repeat.for="error of nameErrors">${error.result.message}</li> </ul> </div> <div validation-errors.from-view="emailErrors"> <label> Email <input value.bind="person.email & validate:changeOrBlur"> </label> <p class="error" repeat.for="error of emailErrors">${error.result.message}</p> </div> </form>
Checklist
Typing and blurring a field shows validation messages immediately.
Submitting runs
controller.validate()and prevents submission whenresult.validis false.Removing the
validation-errorsattribute stops error collections, confirming the bindings are wired correctly.
2. Step-by-step wizard gating
Goal: Prevent users from advancing to the next wizard step until the current step’s model is valid, while keeping previous steps untouched.
Steps
Model each step separately and register them with the controller:
export class CheckoutWizard { shipping = { street: '', city: '' }; payment = { cardNumber: '', expiry: '' }; controller = resolve(newInstanceForScope(IValidationController)); constructor(private rules = resolve(IValidationRules)) { this.rules.on(this.shipping) .ensure('street').required() .ensure('city').required(); this.rules.on(this.payment) .ensure('cardNumber').required().matches(/^[0-9]{16}$/) .ensure('expiry').required(); } async goTo(step: 'shipping' | 'payment') { const target = step === 'payment' ? this.shipping : this.payment; const { valid } = await this.controller.validate({ object: target }); if (!valid) return; this.currentStep = step; } }Bind each step’s inputs to the respective object and call
goTo('payment')on the “Continue” button.Use
controller.reset({ object: this.payment })when users move backward so stale errors disappear.
Checklist
Clicking “Continue” on shipping does nothing until the required fields pass validation.
Moving back to shipping clears payment errors (thanks to
controller.reset).Each step validates only its own object, so partial progress is preserved.
3. Merge API validation errors
Goal: Display server-side validation failures next to the relevant controls after submitting the form.
Steps
Submit the form through the controller so client rules run first:
async submit() { const result = await this.controller.validate(); if (!result.valid) return; const response = await saveUser(this.person); if (!response.ok) { const payload = await response.json(); payload.errors.forEach(err => { this.controller.addError(err.message, this.person, err.property); }); } }Store references to server errors (the return value from
addError) and callcontroller.removeError(error)after the next successful submission or when the user edits that field.
Checklist
When the API returns
{ errors: [{ property: 'email', message: 'Email already used' }] }, the message appears under the email input without reloading.Editing the field and re-validating removes the manual error via
removeError.Server-only properties (like
paymentToken) can still be surfaced by passing apropertyNameeven if there is no binding.
4. Validate only changed fields on large forms
Goal: Skip expensive validation when only one field changes by using validation triggers strategically.
Steps
Leave the global configuration at the default
focusouttrigger.Override the behavior per binding using
& validate:manualfor fields that should only validate on submit.Call
controller.validate({ object: this.model, propertyName: 'taxId' })inside a watcher or setter when you do want to validate a single property.
Checklist
Fields marked with
validate:manualdon’t show inline errors until submit.Calling
validate({ propertyName })updates only the specified property’s errors.The controller’s
resultsarray stays small even on big forms.
5. Cross-field confirmation (password + confirm password)
Goal: Enforce matching values across multiple properties while keeping the error message attached to the confirmation input.
Steps
Define both rules within the same
on(this.account)chain so the validator has access to the whole object:export class AccountForm { account = { password: '', confirmPassword: '' }; controller = resolve(newInstanceForScope(IValidationController)); constructor(private rules = resolve(IValidationRules)) { this.rules.on(this.account) .ensure('password') .required() .minLength(8) .ensure('confirmPassword') .required() .satisfies((value, obj) => value === obj.password) .withMessage('Passwords must match'); } }Bind both inputs with
& validate:changeOrBlurso the confirmation field updates when either value changes.Optionally call
controller.validate({ object: this.account, propertyName: 'confirmPassword' })inside a watcher for instant feedback.
Checklist
Editing the primary password re-validates the confirmation rule.
The error message appears under the confirm field, not both fields.
Submitting the form when passwords differ keeps the user on the form with clear feedback.
6. Async validation against a backend
Goal: Check username availability by calling an API and surface the response inline without spamming the server.
Steps
Create an async rule that calls your service and returns
trueorfalse:export class UsernameForm { model = { username: '' }; controller = resolve(newInstanceForScope(IValidationController)); constructor(private rules = resolve(IValidationRules), private users = resolve(UserService)) { this.rules.on(this.model) .ensure('username') .required() .satisfies(async value => { if (!value) return false; return await this.users.isAvailable(value); }) .withMessage('That username is already taken'); } async checkAvailability() { await this.controller.validate({ object: this.model, propertyName: 'username' }); } }Trigger
checkAvailabilityfrom a blur handler or a debounced input event to limit API calls.Display errors with
validation-errorsorvalidation-containeras in earlier recipes.
Checklist
Valid values resolve quickly and remove the error state.
The API only executes when the field changes or loses focus (thanks to the explicit call).
Network errors can be translated to user-friendly messages by catching exceptions inside
satisfies.
Validation flow cheat sheet
Validate everything before submit
controller.validate()
& validate on inputs
Validate one object/step
controller.validate({ object })
Bind errors via validation-errors
Validate one property on demand
controller.validate({ object, propertyName: 'email' })
validate:manual on that input
Display API errors
controller.addError(message, object, 'property')
Render via validation-errors
Refer back to the core docs for deeper dives:
Last updated
Was this helpful?