GitHub

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

RuleTypeDescription
requiredbooleanField must be present and non-empty.
minLengthnumberString must be at least N characters.
maxLengthnumberString must be at most N characters.
minnumberNumeric value must be ≥ N.
maxnumberNumeric value must be ≤ N.
patternRegExp | stringValue must match the regular expression.
formatstringNamed format: email, url, or numeric.

Named formats

FormatWhat it checks
emailBasic email structure — must contain @ and a domain.
urlMust start with http:// or https://.
numericMust 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:

  1. onStart captures FormData values into state
  2. Validation reads those values from state using dot-paths
  3. If any rules fail, onError is called immediately — run is skipped
Validation reads from state, not from raw 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' },
}