Validation
Validation rules are declared in the spec, co-located with the state they guard. When an action sets validate: true, Pulse enforces every rule before the async work runs. Invalid data cannot reach run().
Declaring validation rules
The validation field maps dot-path state keys to rule objects:
export default {
route: '/signup',
state: {
fields: { name: '', email: '', age: '', website: '' },
},
validation: {
'fields.name': { required: true, minLength: 2, maxLength: 100 },
'fields.email': { required: true, format: 'email' },
'fields.age': { required: true, min: 18, max: 120 },
'fields.website': { format: 'url' }, // optional field, but must be valid URL if provided
},
// ...
}
Available rules
| Rule | Type | Description |
|---|---|---|
required | boolean | Field must be present and non-empty. |
minLength | number | String must be at least N characters. |
maxLength | number | String must be at most N characters. |
min | number | Numeric value must be ≥ N. |
max | number | Numeric value must be ≤ N. |
pattern | RegExp | string | Value must match the regular expression. |
format | string | Named format: email, url, or numeric. |
Named formats
| Format | What it checks |
|---|---|
email | Basic email structure — must contain @ and a domain. |
url | Must start with http:// or https://. |
numeric | Must consist entirely of digit characters. |
Dot-path notation
Validation keys are dot-paths into the current state, allowing nested fields to be validated without any special syntax:
state: {
billing: {
address: { street: '', city: '', postcode: '' },
card: { number: '', expiry: '' },
},
}
validation: {
'billing.address.street': { required: true },
'billing.address.city': { required: true },
'billing.address.postcode': { required: true, pattern: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/i },
'billing.card.number': { required: true, format: 'numeric', minLength: 16, maxLength: 16 },
'billing.card.expiry': { required: true },
}
When validation runs
Validation only runs when an action declares validate: true. The order is enforced by the framework:
onStartcapturesFormDatavalues into state- Validation reads those values from state using dot-paths
- If any rules fail,
onErroris called immediately —runis skipped
FormData. onStart must copy form values into state first — this is what makes them available to dot-path rules.Error structure
When validation fails, the runtime throws an error object with a validation array:
{
message: 'Validation failed',
validation: [
{ field: 'fields.email', rule: 'format', message: 'Must be a valid email address' },
{ field: 'fields.name', rule: 'required', message: 'Required' },
{ field: 'fields.age', rule: 'min', message: 'Must be at least 18' },
]
}
In your action's onError, check for err?.validation to distinguish validation errors from other failures:
onError: (state, err) => ({
status: 'error',
errors: err?.validation ?? [{ message: err.message }],
})
Rendering errors
The errors array maps to UI in the view — a global error list, or inline errors using the field property to place them next to each input:
view: (state) => {
const errFor = (field) => {
const e = state.errors.find(e => e.field === field)
return e ? `<p class="field-error">${e.message}</p>` : ''
}
return `
<form data-action="submit">
<label>
Email
<input name="email" type="email" value="${state.fields.email}">
${errFor('fields.email')}
</label>
<label>
Name
<input name="name" type="text" value="${state.fields.name}">
${errFor('fields.name')}
</label>
<button>Submit</button>
</form>
`
}
Optional fields
Omit required: true for optional fields. Other rules (format, minLength, etc.) are only enforced when the field has a value — empty optional fields always pass:
validation: {
// website is optional, but must be a valid URL if provided
'fields.website': { format: 'url' },
}