Skip to content

Validation and accessible errors

@samline/forms calculates errors, sets aria-invalid, and focuses the first invalid field after a failed submit. Your application owns labels, error text, live announcements, and styling.

  1. Add labels and persistent message containers

    Use novalidate when the controller should own the validation messages. Without it, native constraint validation can stop the submit event before the controller runs.

    <form id="signup" novalidate>
    <div class="field">
    <label for="signup-email">Email</label>
    <input
    id="signup-email"
    name="email"
    type="email"
    autocomplete="email"
    aria-describedby="signup-email-error"
    />
    <p id="signup-email-error" class="field-error"></p>
    </div>
    <div class="field">
    <label for="signup-password">Password</label>
    <input
    id="signup-password"
    name="password"
    type="password"
    autocomplete="new-password"
    aria-describedby="signup-password-error"
    />
    <p id="signup-password-error" class="field-error"></p>
    </div>
    <div id="signup-summary" role="alert" aria-live="polite"></div>
    <button type="submit">Create account</button>
    </form>
  2. Configure validation

    import { form } from '@samline/forms'
    const signup = form('signup', {
    validators: {
    email: {
    required: { value: true, message: 'Enter your email address.' },
    pattern: {
    value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    message: 'Enter a valid email address.'
    }
    },
    password: {
    required: { value: true, message: 'Choose a password.' },
    minLength: { value: 8, message: 'Use at least 8 characters.' }
    }
    }
    })
  3. Render every state update

    subscribe() invokes the listener immediately and after controller notification points. It is safer than reading getState() once.

    const errorNodes = {
    email: document.querySelector<HTMLElement>('#signup-email-error')!,
    password: document.querySelector<HTMLElement>('#signup-password-error')!
    }
    const summary = document.querySelector<HTMLElement>('#signup-summary')!
    signup.subscribe(state => {
    for (const [name, node] of Object.entries(errorNodes)) {
    node.textContent = state.errors[name]?.join(' ') ?? ''
    }
    const messages = Object.values(state.errors).flat()
    summary.textContent = messages.length > 0
    ? `Please fix ${messages.length} ${messages.length === 1 ? 'error' : 'errors'}.`
    : ''
    })
    signup.onSubmit(async (_form, _data, formData) => {
    const response = await fetch('/api/signup', { method: 'POST', body: formData })
    if (response.status === 422) {
    const body = await response.json() as { fieldErrors: Record<string, string[]> }
    signup.setErrors(body.fieldErrors)
    }
    })
Rule Empty value Strings Arrays and files
required Fails when empty. Trims whitespace before deciding. Fails when the array is empty.
minLength Runs even when empty. Uses JavaScript string length. Uses item count.
maxLength Runs even when empty. Uses JavaScript string length. Uses item count.
pattern Skipped. Tests the string. Tests a comma-joined string; files contribute their names.
numeric Skipped. Requires a finite signed decimal string. Fails because collections are not decimal strings.
min Skipped. Requires a numeric value greater than or equal to the bound. Fails numeric parsing before comparison.
max Skipped. Requires a numeric value less than or equal to the bound. Fails numeric parsing before comparison.
sameAs Skipped until both fields have values. Exact, case-sensitive equality. Ordered item equality; files compare by object identity.
validate Always runs. Receives the normalized value and all form values. Receives the array unchanged.

All configured rules run and their messages accumulate. Custom validators still run when a built-in rule failed. Stateful regular expressions are safe because lastIndex is reset before and after each test.

Numeric validation is intentionally strict. After trimming surrounding whitespace it accepts signed decimal forms such as -12, +3.5, 4., and .75. It rejects exponent notation (1e3), hexadecimal (0x10), Infinity, separators, and partial numbers. min and max imply numeric parsing, use inclusive comparisons, and report a numeric error for a non-empty non-number before checking the bound.

Use sameAs for password, email, PIN, or account-number confirmation:

const signup = form('signup-form', {
validators: {
password: { required: true, minLength: 8 },
password_confirmation: {
required: true,
sameAs: {
value: 'password',
message: 'Passwords do not match.'
}
}
}
})

The declaration creates a one-way validation dependency: password_confirmation owns the error and is automatically revalidated when password changes after validation is active. The default autoValidate: true activates validation during construction; with autoValidate: false, call validate() or revalidate() once to activate it. Changes made through setValue() use the same input pipeline. Do not add a watcher just to call revalidate().

Things to avoid:

  • Do not use sameAs instead of required; equality is skipped while either value is empty.
  • Do not rely on a _confirmation naming convention. The value must be the exact field name.
  • Do not expect unordered collection equality. Arrays must contain the same entries in the same order.
  • Do not add reciprocal rules to force source-field revalidation. The dependency is already inferred from the confirmation rule.

Circular dependencies cannot recurse indefinitely. The controller collects affected fields iteratively with a visited set, then validates each field at most once for that input event. This avoids event loops and does not install per-field listeners.

Custom validators can read any value, but the controller needs explicit metadata to know which source changes should trigger them:

form('booking', {
validators: {
end_date: {
dependsOn: ['start_date', 'timezone'],
validate: ({ value, values }) =>
isValidRange(values.start_date, value, values.timezone)
? null
: 'Choose an end date after the start date.'
}
}
})

dependsOn accepts exact field names, adds no message itself, and uses the same cycle-safe transitive traversal as sameAs. With autoValidate: false, reactive validation starts after the first validate() or revalidate() call.

Use group rules for the collection and each for each concrete control:

form('signers', {
validators: {
'signer_email[]': {
minLength: { value: 2, message: 'Add at least two signers.' },
each: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
validate: ({ element, index }) =>
element?.disabled
? `Signer ${index! + 1} must be enabled.`
: null
}
}
}
})

Rules inside each receive one member as value, the concrete element when a controller validates DOM fields, and its zero-based index. Group rules still receive the aggregate field value. FormErrors deliberately remains Record<string, string[]>: member messages are flattened under the field name in member order. Internally, the controller retains element ownership so css-error and aria-invalid appear only on failing members; a group-level or manual error still marks the whole group.

Validator keys are literal HTML field names. Wildcards and object-path expansion are not supported:

form('items', {
validators: {
'rows[].name': { required: true } // matches name="rows[].name"
}
})

rows[*].name does not match rows[0].name. For indexed names, create exact rules before constructing the controller or validate a values object with validateValues().

  • Validation errors come from validators and are recomputed by validate().
  • Manual errors come from setErrors() and remain separate internally.
  • Returned state merges both maps.
  • clearErrors() clears only manual errors.
  • clearErrorsOnSubmit and clearManualErrorsOnChange control when manual errors disappear.
  • A partial validate(['email']) preserves existing validation errors for fields not requested.

getState() is a pure snapshot; it never runs validation. With the default autoValidate: true, isValidated starts as true because initial validation runs during construction. With autoValidate: false, an invalid form can report isValid: true until you call validate() or submit it. Initial css-filled synchronization still runs in either mode and is not validation.

Direct validate() and revalidate() calls update validation state and visual attributes but do not independently notify subscribers. Render from their returned result when you need synchronous UI feedback.