Skip to content

ValidationError

ValidationError is the discriminated union returned by PhoneNumber.getValidationError(): when a number is not valid, it names the class of failure and carries the values needed to describe it. Digits drive one deterministic automaton compiled from Google’s metadata; validity is an accepting state, and every ValidationError variant is a named way of not being in one.

The union is produced by the shared resolve layer, so parsePhoneNumber and the input controller report identical errors for identical digits, queried in batch versus per keystroke.

import type { ValidationError } from '@telixon/core';

ValidationError is a type-only export and needs no engine. Producing a value does: it is read from a PhoneNumber, which requires the engine to be ready.

Verbatim from the source:

/** Structured validation outcome beyond `valid: boolean`. Kind values mirror libphonenumber where applicable. */
export type ValidationError =
| { readonly kind: 'EMPTY' }
| { readonly kind: 'INVALID_CALLING_CODE' }
| { readonly kind: 'TOO_SHORT'; readonly minLength: number }
| { readonly kind: 'TOO_LONG'; readonly maxLength: number }
| { readonly kind: 'INVALID_LENGTH'; readonly possibleLengths: readonly number[] }
| { readonly kind: 'POSSIBLE_LOCAL_ONLY' }
| { readonly kind: 'PATTERN_MISMATCH' }
| { readonly kind: 'NATIONAL_PREFIX_MISSING'; readonly expectedPrefix: string };

The accessor on PhoneNumber:

getValidationError(): ValidationError | null;

Every field is readonly; treat payloads as immutable. As the source comment states, kind values mirror Google libphonenumber where applicable, so TOO_SHORT, TOO_LONG, and INVALID_LENGTH mean exactly what they mean there.

Each variant names a distinct class of non-accepting state. Every payload below is the executed output for its repro input; (US) and (GB) mean the input was parsed with { defaultRegion: 'US' } or { defaultRegion: 'GB' }.

Variant Payload Triggering condition Repro input (region) isValid()
EMPTY none No digits and no calling code in the input '' false
INVALID_CALLING_CODE none The digits after + match no calling code '+999 123 4567' false
TOO_SHORT minLength: 10 Fewer national significant digits than any possible number '415 555' (US) false
TOO_LONG maxLength: 10 More national significant digits than any possible number '(415) 555-01320' (US) false
INVALID_LENGTH possibleLengths: [9, 12] The digit count matches none of the allowed lengths '+41 44 668 18 000' false
POSSIBLE_LOCAL_ONLY none Possible only for local dialing, such as a subscriber number without its area code '555 0132' (US) false
PATTERN_MISMATCH none The length is possible, but the digits end in a non-accepting state '+1 123 456 7890' false
NATIONAL_PREFIX_MISSING expectedPrefix: '0' Valid digits, but national-mode input omitted a required trunk prefix '7400 123456' (GB) true

NATIONAL_PREFIX_MISSING is the one advisory variant: isValid() stays true. See the advisory variant.

getValidationError() returns ValidationError | null. null means nothing is wrong: the digits are in an accepting state and no advisory applies.

import { ensureEngineReady, parsePhoneNumber } from '@telixon/core';
await ensureEngineReady();
parsePhoneNumber('415 555', { defaultRegion: 'US' }).getValidationError();
// { kind: 'TOO_SHORT', minLength: 10 }
parsePhoneNumber('+1 415 555 0132').getValidationError();
// null

getValidationError() itself never throws. The error surface is upstream: calling parsePhoneNumber before the engine is initialized throws EngineNotReadyError, so no PhoneNumber exists to query. See ensureEngineReady.

Map variants to messages; never re-derive the conditions. The payload already carries every value a message needs, computed by the same automaton that decided validity.

Checks run in a fixed order and the first match wins, so at most one variant is ever reported:

  1. EMPTY
  2. INVALID_CALLING_CODE
  3. TOO_SHORT
  4. TOO_LONG
  5. INVALID_LENGTH
  6. POSSIBLE_LOCAL_ONLY
  7. PATTERN_MISMATCH
  8. NATIONAL_PREFIX_MISSING

Length variants report before PATTERN_MISMATCH deliberately. A wrong-length number usually fails the pattern too, but the length variant carries the expected digit count, so the copy built from it stays concrete: “at least 10 digits” instead of a generic rejection.

// 9 national digits: both too short and pattern-invalid. Length wins.
parsePhoneNumber('+1 123 456 789').getValidationError();
// { kind: 'TOO_SHORT', minLength: 10 }

minLength, maxLength, and possibleLengths count national significant digits. The calling code and the national prefix are excluded.

// 12 digits typed, but only the 10 after the calling code count ('4466818000').
// Swiss numbers allow 9 or 12 national significant digits; 10 matches neither.
parsePhoneNumber('+41 44 668 18 000').getValidationError();
// { kind: 'INVALID_LENGTH', possibleLengths: [ 9, 12 ] }
// 11 digits typed, but the leading '1' is the US national prefix:
// 10 national significant digits remain, and the number is valid.
parsePhoneNumber('1 415 555 0132', { defaultRegion: 'US' }).getValidationError();
// null

isPossibleWithReason() returns the coarse possibility verdict (PossibilityResult); getValidationError() returns the classified failure. The two share vocabulary: INVALID_CALLING_CODE, TOO_SHORT, TOO_LONG, and INVALID_LENGTH name the same states in both unions.

isPossibleWithReason() getValidationError()
'IS_POSSIBLE' null, PATTERN_MISMATCH, or NATIONAL_PREFIX_MISSING
'IS_POSSIBLE_LOCAL_ONLY' POSSIBLE_LOCAL_ONLY
'INVALID_CALLING_CODE' INVALID_CALLING_CODE, or EMPTY for empty input
'TOO_SHORT' TOO_SHORT
'TOO_LONG' TOO_LONG
'INVALID_LENGTH' INVALID_LENGTH

Possibility is a length-level verdict, so it cannot separate a valid number from one that fails the pattern; getValidationError() resolves that remainder into null, PATTERN_MISMATCH, or NATIONAL_PREFIX_MISSING. The EMPTY row shows precedence at work: empty input has no calling code, so its coarse verdict is 'INVALID_CALLING_CODE', but the classification checks EMPTY first.

const empty = parsePhoneNumber('');
empty.isPossibleWithReason(); // 'INVALID_CALLING_CODE'
empty.getValidationError(); // { kind: 'EMPTY' }

NATIONAL_PREFIX_MISSING is the only variant that coexists with isValid() === true. The digits are in an accepting state; what is missing is the trunk prefix that national dialing conventions require in front of them. The payload names it, and every format method emits it.

const phoneNumber = parsePhoneNumber('7400 123456', { defaultRegion: 'GB' });
phoneNumber.getValidationError(); // { kind: 'NATIONAL_PREFIX_MISSING', expectedPrefix: '0' }
phoneNumber.isValid(); // true
phoneNumber.formatNational(); // '07400 123456'
phoneNumber.formatE164(); // '+447400123456'

Only national-mode input can trigger it. International input legitimately omits the trunk prefix, so a number entered with + never reports this variant:

parsePhoneNumber('+44 7400 123456').getValidationError();
// null

The variant is also absent when the matching format marks the prefix optional, as US formats do.

The canonical consumption pattern is one exhaustive switch over kind. All eight cases return, so under noImplicitReturns TypeScript rejects the function if any case is missing.

import type { ValidationError } from '@telixon/core';
export function toMessage(error: ValidationError): string {
switch (error.kind) {
case 'EMPTY':
return 'Enter a phone number.';
case 'INVALID_CALLING_CODE':
return 'No country matches this calling code.';
case 'TOO_SHORT':
return `At least ${error.minLength} digits are required.`;
case 'TOO_LONG':
return `At most ${error.maxLength} digits are allowed.`;
case 'INVALID_LENGTH':
return `Expected ${error.possibleLengths.join(' or ')} digits.`;
case 'POSSIBLE_LOCAL_ONLY':
return 'Add the area code.';
case 'PATTERN_MISMATCH':
return 'This number does not exist in its numbering plan.';
case 'NATIONAL_PREFIX_MISSING':
return `Start the number with ${error.expectedPrefix}.`;
}
}

error === null implies isValid(), but the reverse fails for the advisory variant. Treating any non-null error as invalid misclassifies valid input:

const phoneNumber = parsePhoneNumber('7400 123456', { defaultRegion: 'GB' });
// Wrong: rejects a valid number.
const rejected = phoneNumber.getValidationError() !== null; // true
// Right: gate on validity, use the error for the message or the fix-up.
phoneNumber.isValid(); // true
phoneNumber.formatNational(); // '07400 123456'
  • PhoneNumber: the handle whose getValidationError() produces this union.
  • parsePhoneNumber: the batch entry into the shared resolve layer.
  • The model: why validity is an accepting state.