Skip to content

Validation feedback

Every PhoneInput state carries validationError: the structured outcome of PhoneNumber.getValidationError() for the digits currently in the field, or null when the number is complete and valid. It is recomputed on every state change, so a subscriber sees it move from a partial-number kind to null as the user finishes typing. This guide covers two decisions: when to surface it, and how to turn each kind into a message. It assumes a PhoneInput attached as in getting started.

state.validationError is a discriminated union on kind. Read it from any state snapshot:

const error = phoneInput.getState().validationError;
if (error === null) {
// The number is complete and valid.
} else {
// error.kind names the problem; some kinds carry a payload.
}

The kinds and their payloads:

kind Payload Meaning
EMPTY none No digits and no calling code yet.
TOO_SHORT minLength: number Fewer digits than any valid length for the region.
TOO_LONG maxLength: number More digits than any valid length for the region.
INVALID_LENGTH possibleLengths: readonly number[] A length that falls between valid lengths, not on one.
NATIONAL_PREFIX_MISSING expectedPrefix: string The region’s format requires a trunk prefix the digits omit.
INVALID_CALLING_CODE none The leading digits match no calling code.
POSSIBLE_LOCAL_ONLY none Valid only for local dialing, e.g. a subscriber number without its area code.
PATTERN_MISMATCH none A valid length, but the digits match no number pattern.

EMPTY is the least intuitive: it appears only when no region is resolved at all. In national mode, and in international mode with a defaultRegion, an empty field already has that region’s length rules in scope, so an empty value reports TOO_SHORT, not EMPTY. EMPTY reaches you only in international mode without a defaultRegion, before the typed digits resolve a calling code.

Because the error is live, reflecting it directly would flag the field the instant it gains focus and keep it flagged through every intermediate length. Gate the display on a commit point. The minimal gate is a touched flag set on the first blur:

// inputElement and errorElement come from your markup.
let touched = false;
function render(): void {
const error = phoneInput.getState().validationError;
const showError = touched && error !== null;
errorElement.textContent = showError ? messageFor(error) : '';
}
inputElement.addEventListener('blur', () => {
touched = true;
render();
});
phoneInput.subscribe(render);

After the first blur the subscriber keeps the message in sync on every keystroke, so it clears the moment the number becomes valid and reappears if the user empties the field again. Two common variants:

  • Show on submit only. Set touched = true in the submit handler instead of on blur, then call render(). Nothing appears until the user tries to submit.
  • Suppress while typing forward, show while correcting. Keep the blur gate, but also clear the message whenever the value grows, so an in-progress number never shows TOO_SHORT. Compare the new state.value.length against the previous one inside the subscriber.

Whether EMPTY counts as an error is your form’s required rule. For an optional field, treat EMPTY as no error even when touched is true:

const showError = touched && error !== null && error.kind !== 'EMPTY';

Switch on kind and read the payload where one exists. The payload is what lets the message name a concrete number instead of a generic complaint:

import type { ValidationError } from '@telixon/core';
function messageFor(error: ValidationError): string {
switch (error.kind) {
case 'EMPTY':
return 'Enter a phone number.';
case 'TOO_SHORT':
return `The number is too short. It needs at least ${error.minLength} digits.`;
case 'TOO_LONG':
return `The number is too long. It has at most ${error.maxLength} digits.`;
case 'INVALID_LENGTH':
return `The number has the wrong number of digits. Valid lengths: ${error.possibleLengths.join(', ')}.`;
case 'NATIONAL_PREFIX_MISSING':
return `Start the number with ${error.expectedPrefix}.`;
case 'INVALID_CALLING_CODE':
return 'The calling code is not recognized.';
case 'POSSIBLE_LOCAL_ONLY':
return 'This looks like a local number. Add the area code.';
case 'PATTERN_MISMATCH':
return 'Check the number. It does not match a valid pattern.';
}
}

The switch is exhaustive over the union, so adding a case for a new kind is a type error until you handle it. The same mapping applies outside the DOM: form errors maps every kind to copy for server-side and framework validation using the same ValidationError type.

The payload numbers are region-specific and come from the resolved region. For defaultRegion: 'US' a partial number reports { kind: 'TOO_SHORT', minLength: 10 }; a United Arab Emirates mobile typed without its trunk prefix reports { kind: 'NATIONAL_PREFIX_MISSING', expectedPrefix: '0' }, and the error clears to null once the 0 is present.

Rendering the message into a live region announces it to screen readers, and reflecting the same gated boolean into aria-invalid marks the field. Both belong on the commit point, not on every keystroke. Accessibility covers the live-region markup, aria-invalid, and aria-describedby wiring that surrounds this timing model.