Skip to content

The model

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. parsePhoneNumber and the input controller are the same shared resolve layer, queried in batch versus per keystroke.

That is the whole model. The rest of this page unpacks each sentence.

Resolving a number is one pass over the input, left to right. Non-digit characters are discarded as the walk goes; each kept digit is one state transition.

input "+1 (415) 555-0132"
kept + 1 4 1 5 5 5 5 0 1 3 2 spaces, '(', ')', '-' discarded
walk s0 --1--> s1 --4--> s2 --1--> s3 --5--> s4 --5--> s5 --5--> s6
s6 --5--> s7 --0--> s8 --1--> s9 --3--> s10 --2--> ((s11))
s0 root; the leading '+' selects the international entry
s1 after '1': calling code resolved
((s11)) accepting state; isValid() is true here

The state ids s0 through s11 are schematic labels for this diagram; the engine’s real state numbers are internal and not part of the API. The shape, however, is exact: eleven kept digits, eleven transitions, one path. A deterministic automaton has at most one transition per digit from any state, so there is no candidate list, no backtracking, and no second pass. Every answer the API gives is a property of where this path ends.

Google’s metadata for 245 regions compiles ahead of time into one unified automaton. There is no per-region table selected at parse time and no region-by-region matching loop: every number in the world walks the same machine from the same root. How the metadata becomes this machine is described on Engine.

Regions and number types are filters over the same walk, not separate parses. The end state of a walk carries which regions and which number types accept the digits, and each query method reads that end state through a different filter:

const number = parsePhoneNumber('+1 415 555 0132');
number.getRegion(); // 'US'
number.getNumberType(); // 'FIXED_LINE_OR_MOBILE'
number.isValidForRegion('US'); // true
number.isValidForRegion('GB'); // false

The walk happens once, inside parsePhoneNumber. Asking a different question afterwards means applying a different filter to the stored end state, not parsing again. The full set of query methods is on PhoneNumber.

isValid() is true exactly when the walk ends in an accepting state. There is no validation step after parsing; the verdict is a property of the end state, decided the moment the last digit is consumed.

Every ValidationError variant names a class of non-accepting end:

parsePhoneNumber('415 555', { defaultRegion: 'US' }).getValidationError();
// { kind: 'TOO_SHORT', minLength: 10 }
parsePhoneNumber('+1 123 456 7890').getValidationError();
// { kind: 'PATTERN_MISMATCH' }

The first walk stopped after seven digits, before any accepting state was reachable; minLength names the smallest digit count that could have reached one. The second consumed a plausible digit count (its isPossibleWithReason() is 'IS_POSSIBLE') but stepped off every accepting path. This is why possibility and validity are distinct verdicts: possibility asks whether the digit count could end in an accepting state, validity asks whether this exact path did. The complete variant list, with payloads and the inputs that produce each one, is on ValidationError.

parsePhoneNumber is the batch entry: it takes a finished string and performs the walk once, end to end. The input controller is the per-keystroke entry: it holds the current walk and advances or rewinds it on every edit, keeping history and caret placement as controller state layered on top. Same machine, same resolve layer, two query rhythms.

Because both entries are the same walk, the verdict for a given digit sequence cannot differ between them:

import { createInternationalInputController, ensureEngineReady, parsePhoneNumber } from '@telixon/core';
await ensureEngineReady();
// Batch: one walk over the whole string.
const parsed = parsePhoneNumber('+44 20 7946 0958');
parsed.isValid(); // true
parsed.formatE164(); // '+442079460958'
// Per keystroke: the same walk, stepped by the controller.
const controller = createInternationalInputController();
controller.setValue('+44 20 7946 0958').region; // 'GB'
const typed = controller.getPhoneNumber();
typed.isValid(); // true
typed.formatE164(); // '+442079460958'

getPhoneNumber() returns the same PhoneNumber contract that parsePhoneNumber returns, so validation written against one entry holds for the other. How the controller steps the walk, and what its history and caret state consist of, is on Input controller; why stepping it is cheap enough to run on every keystroke is on Performance.

Determinism. Same input, same path, same answer. There is exactly one transition per digit, so no heuristic ordering, locale, or environment detail participates in a verdict. The only things that change an answer are the inputs you pass: the string, defaultRegion, strict, and any region or number type filters.

Provability. A finite machine can be enumerated exhaustively, which is how parity with Google libphonenumber was verified; the enumeration and its re-verification in CI are documented on Verified.

  • Engine describes the artifact behind the walk: how the compiled tables are built, shipped, and loaded.
  • Input controller follows the same walk stepped per keystroke.
  • ValidationError is the full contract for the named non-accepting states.
  • Verified documents the exhaustive enumeration this page appeals to.