UNPKG

@jakguru/phone-object

Version:

An immutable data structure representing a specific phone number and accompanying methods. It contains class and instance methods of creating, parsing, validating, and formatting phone numbers. Based on google-libphonenumber, which is in turn based on Goo

693 lines (653 loc) 24.1 kB
/** * The `allCountries` variable is imported from the `country-telephone-data` library and represents an array of objects containing information about all countries recognized by the library. * @type {Array<Country>} */ import { allCountries } from 'country-telephone-data' /** * The `libPhoneNumber` module is imported from the `google-libphonenumber` library and contains functions for parsing, formatting, and validating international phone numbers. * @namespace * @alias libPhoneNumber */ import * as libPhoneNumber from 'google-libphonenumber' /** * The `Country` type is imported from the `./countries` module and represents a country's ISO 3166-1 alpha-2 code */ import type { Country } from './countries' /** * The `CountryOrUnknown` type is imported from the `./countries` module and represents a country's ISO 3166-1 alpha-2 code, or an unknown country represented by the `'XX'` code. * @typedef {Country | 'XX'} CountryOrUnknown */ import type { CountryOrUnknown } from './countries' /** * The `CountryTimezone` type is imported from the `./countries` module and represents a luxon timezone name * @typedef {string} CountryTimezone */ import type { CountryTimezone } from './countries' /** * The `isos` variable is imported from the `./countries` module and represents a set of all ISO 3166-1 alpha-2 codes recognized by the library. * @type {Set<Country>} */ import { isos } from './countries' /** * The `timezones` variable is imported from the `./countries` module and represents an object containing information about all timezones recognized by the library. * @type {Object<Country, CountryTimezone>} */ import { timezones } from './countries' /** * The `areaCodeMap` variable is imported from the `./nampa` module and represents a map of NANPA area codes to NANPA country codes. */ import { areaCodeMap } from './nampa' /** * The `nanpaCountries` variable is imported from the `./nampa` module and represents a list of NANPA country codes. */ import { nanpaCountries } from './nampa' /** * The `RawPhoneType` type represents the phone number which should be parsed. It can be either a string or a number. * @typedef {string | number} RawPhoneType */ export type RawPhoneType = string | number /** * The `RawCountryType` type represents the country which should be used to parse the phone number. It is of type `Country`. * @typedef {Country} RawCountryType */ export type RawCountryType = Country /** * The `PossiblePhoneTimezone` type represents the estimated timezone of a phone number based on the phone number's country. It can be either a `CountryTimezone` or `'UTC'`. * @typedef {CountryTimezone | 'UTC'} PossiblePhoneTimezone * @remarks * * In cases where the phone number's country has several timezones, the timezone with the greatest population is used. * In cases where the phone number's country is not recognized or has no timezone defined, `'UTC'` is used. */ export type PossiblePhoneTimezone = CountryTimezone | 'UTC' /** * The `PhoneTypes` type represents the possible types of a phone number. It can be any of the keys of the `PhoneNumberType` object in the `google-libphonenumber` library, or `'INVALID'` if the phone number is not valid. * @typedef {keyof typeof libPhoneNumber.PhoneNumberType | 'INVALID'} PhoneTypes */ export type PhoneTypes = keyof typeof libPhoneNumber.PhoneNumberType | 'INVALID' /** * The `PhoneModelInstanceObject` interface represents the properties of a `Phone` instance. * @interface */ export interface PhoneModelInstanceObject { /** * The `phone` property represents the raw phone number string that was used to create the `Phone` instance. * @type {string} */ phone: string /** * The `country` property represents the country that was used to create the `Phone` instance. * @type {CountryOrUnknown} * @remarks * * In cases where the country was not recognized and could not be guessed from the phone number, `'XX'` is used. */ country: CountryOrUnknown /** * The `valid` property represents whether the phone number uses a valid format for the parsed country or not. * @type {boolean} */ valid: boolean /** * The `type` property represents the type of the phone number. It can be any of the keys of the `PhoneNumberType` object in the `google-libphonenumber` library, or `'INVALID'` if the phone number is not valid. * @type {PhoneTypes} */ type: PhoneTypes /** * The `mobile` property represents whether the phone number is possibly a mobile phone number or not. * @type {boolean} * @remarks * * This property is `true` if the phone number type is either `'MOBILE'` or `'FIXED_LINE_OR_MOBILE'`. * This covers cases like in the United States and Canada where it is not possible to tell if a phone number is a mobile phone number or not based on the phone number alone. */ mobile: boolean /** * The `raw` property represents the phone number as a string stripped of all non-numeric characters. * @type {string} */ raw: string /** * The `national` property represents the phone number as a string in the national format for the parsed country. * @type {string} */ national: string /** * The `international` property represents the phone number as a string in the international format for the parsed country. * @type {string} */ international: string /** * The `e164` property represents the phone number as a string in the E.164 format. * @type {string} */ e164: string /** * The `timezone` property represents the estimated timezone of the phone number based on the phone number's country. It can be either a `CountryTimezone` or `'UTC'`. * @type {PossiblePhoneTimezone} * @remarks * * In cases where the phone number's country has several timezones, the timezone with the greatest population is used. * In cases where the phone number's country is not recognized or has no timezone defined, `'UTC'` is used. */ timezone: PossiblePhoneTimezone } /** * The `PhoneModel` interface represents a phone number object with various properties and methods. * @interface */ export interface PhoneModel { /** * The `country` property represents the country that was used to create the `Phone` instance. * @type {CountryOrUnknown} * @remarks * * In cases where the country was not recognized and could not be guessed from the phone number, `'XX'` is used. */ country: CountryOrUnknown /** * The `valid` property represents whether the phone number uses a valid format for the parsed country or not. * @type {boolean} */ valid: boolean /** * The `type` property represents the type of the phone number. It can be any of the keys of the `PhoneNumberType` object in the `google-libphonenumber` library, or `'INVALID'` if the phone number is not valid. * @type {PhoneTypes} */ type: PhoneTypes /** * The `mobile` property represents whether the phone number is possibly a mobile phone number or not. * @type {boolean} * @remarks * * This property is `true` if the phone number type is either `'MOBILE'` or `'FIXED_LINE_OR_MOBILE'`. * This covers cases like in the United States and Canada where it is not possible to tell if a phone number is a mobile phone number or not based on the phone number alone. */ mobile: boolean /** * The `raw` property represents the phone number as a string stripped of all non-numeric characters. * @type {string} */ raw: string /** * The `national` property represents the phone number as a string in the national format for the parsed country. * @type {string} */ national: string /** * The `international` property represents the phone number as a string in the international format for the parsed country. * @type {string} */ international: string /** * The `e164` property represents the phone number as a string in the E.164 format. * @type {string} */ e164: string /** * The `timezone` property represents the estimated timezone of the phone number based on the phone number's country. It can be either a `CountryTimezone` or `'UTC'`. * @type {PossiblePhoneTimezone} * @remarks * * In cases where the phone number's country has several timezones, the timezone with the greatest population is used. * In cases where the phone number's country is not recognized or has no timezone defined, `'UTC'` is used. */ timezone: PossiblePhoneTimezone /** * The `toObject` method returns an object with the same properties as the `PhoneModelInstanceObject` interface. * @returns {PhoneModelInstanceObject} */ toObject(): PhoneModelInstanceObject /** * The `toJSON` method returns an object with the same properties as the `PhoneModelInstanceObject` interface. * @returns {PhoneModelInstanceObject} */ toJSON(): PhoneModelInstanceObject /** * The `toString` method returns the phone number as a string in the international format for the parsed country. * @returns {string} */ toString(): string } /** * The `Phone` class represents a phone number and provides methods to retrieve information about it. * It implements the `PhoneModel` interface. * @class * @implements {PhoneModel} */ export class Phone implements PhoneModel { /** * The phone number as a string stripped of all non-numeric characters. * @type {string} * @private * @readonly */ readonly #phone: string /** * The country code of the phone number. * @type {CountryOrUnknown} * @private * @readonly */ readonly #country: CountryOrUnknown /** * The `PhoneNumberUtil` instance used to parse and validate phone numbers. * @type {libPhoneNumber.PhoneNumberUtil} * @private * @readonly */ readonly #util: libPhoneNumber.PhoneNumberUtil /** * The parsed `PhoneNumber` object representing the phone number. * @type {libPhoneNumber.PhoneNumber | undefined} * @private * @readonly */ readonly #parsed: libPhoneNumber.PhoneNumber | undefined /** * Whether the phone number uses a valid format for the parsed country or not. * @type {boolean} * @private * @readonly */ readonly #valid: boolean = false /** * The type of the phone number. * @type {PhoneTypes} * @private * @readonly */ readonly #type: PhoneTypes = 'INVALID' /** * The `Phone` class constructor takes a `phone` argument of type `RawPhoneType` and an optional `country` argument of type `RawCountryType`. * It initializes `Phone` instance. * @constructor * @param {RawPhoneType} phone - The phone number to be parsed and validated. * @param {RawCountryType} [country] - The country code of the phone number. If not provided, the country will be guessed based on the phone number. */ constructor(phone: RawPhoneType, country?: RawCountryType) { this.#phone = String(phone).replace(/\D/g, '') const countryTest = 'string' === typeof country ? country.trim().toUpperCase() : 'XX' this.#country = isos.has(countryTest) ? (countryTest as CountryOrUnknown) : 'XX' this.#util = libPhoneNumber.PhoneNumberUtil.getInstance() if (this.#country === 'XX') { this.#country = this.#guessCountry(this.#phone) } try { this.#parsed = this.#util.parseAndKeepRawInput(this.#phone, this.#country) } catch { // noop } if (this.#parsed) { try { this.#valid = this.#util.isValidNumber(this.#parsed) this.#type = this.#getPhoneNumberType(this.#parsed) } catch { // noop } } if (true === this.#valid && nanpaCountries.includes(this.#country)) { this.#country = this.#getCorrectedNANPCountry() } Object.defineProperty(this, 'country', { value: this.country, writable: false }) Object.defineProperty(this, 'valid', { value: this.valid, writable: false }) Object.defineProperty(this, 'type', { value: this.type, writable: false }) Object.defineProperty(this, 'mobile', { value: this.mobile, writable: false }) Object.defineProperty(this, 'raw', { value: this.raw, writable: false }) Object.defineProperty(this, 'national', { value: this.national, writable: false }) Object.defineProperty(this, 'international', { value: this.international, writable: false }) Object.defineProperty(this, 'e164', { value: this.e164, writable: false }) Object.defineProperty(this, 'timezone', { value: this.timezone, writable: false }) Object.freeze(this) } /** * Returns the country code of the phone number. * @readonly * @type {CountryOrUnknown} */ public get country(): CountryOrUnknown { return this.#country } /** * Returns whether the phone number uses a valid format for the parsed country or not. * @readonly * @type {boolean} */ public get valid(): boolean { return this.#valid } /** * Returns the type of the phone number. * @readonly * @type {PhoneTypes} */ public get type(): PhoneTypes { return this.#type } /** * Returns whether the phone number is a mobile number or not. * @readonly * @type {boolean} */ public get mobile() { return 'string' === typeof this.type && ['MOBILE', 'FIXED_LINE_OR_MOBILE'].includes(this.type) } /** * Returns the raw phone number. * @readonly * @type {string} * * @remarks * If the phone number is not a valid phone number, the initial phone number which was passed to the constructor is returned. */ public get raw() { if (!this.#parsed) { return this.#phone } return this.#util.format(this.#parsed, libPhoneNumber.PhoneNumberFormat.E164).replace(/\D/g, '') } /** * Returns the national format of the phone number. * @readonly * @type {string} * * @remarks * If the phone number is not a valid phone number, the initial phone number which was passed to the constructor is returned. */ public get national() { if (!this.#parsed) { return this.#phone } return this.#util.format(this.#parsed, libPhoneNumber.PhoneNumberFormat.NATIONAL) } /** * Returns the international format of the phone number. * @readonly * @type {string} * * @remarks * If the phone number is not a valid phone number, the initial phone number which was passed to the constructor is returned. */ public get international() { if (!this.#parsed) { return this.#phone } return this.#util.format(this.#parsed, libPhoneNumber.PhoneNumberFormat.INTERNATIONAL) } /** * Returns the E.164 format of the phone number. * @readonly * @type {string} * * @remarks * If the phone number is not a valid phone number, the initial phone number which was passed to the constructor is returned. */ public get e164() { if (!this.#parsed) { return this.#phone } return this.#util.format(this.#parsed, libPhoneNumber.PhoneNumberFormat.E164) } /** * Returns the timezone of the phone number. * @readonly * @type {PossiblePhoneTimezone} */ public get timezone(): PossiblePhoneTimezone { if (timezones.has(this.#country)) { return timezones.get(this.#country) as CountryTimezone } return 'UTC' } /** * Guesses the country of a phone number based on its prefix. * @private * @param {string} phone - The phone number to guess the country of. * @returns {CountryOrUnknown} - The ISO 3166-1 alpha-2 code of the guessed country, or 'XX' if the country could not be guessed. */ #guessCountry(phone: string): CountryOrUnknown { const potentials = allCountries .map((c) => ({ iso: c.iso2.toUpperCase(), prefix: String(c.dialCode).trim().replace(/\D/g, ''), })) .filter((c) => phone.substring(0, c.prefix.length) === c.prefix) .sort(this.#sortCountriesByPrefix) .filter((c) => { // done like this because typescript thinks that T.constructor is not a type of T but a plain function // https://stackoverflow.com/a/61444747/10645758 const t = this.constructor as { new (...args: ConstructorParameters<typeof Phone>): Phone } const tp = new t(phone, c.iso) return tp.valid }) if (potentials.length >= 1) { return potentials[0].iso } return this.#guessCountryWithoutPrefix(phone) } #guessCountryWithoutPrefix(phone: string): CountryOrUnknown { const potentials = allCountries .map((c) => ({ iso: c.iso2.toUpperCase(), prefix: String(c.dialCode).trim().replace(/\D/g, ''), })) .sort(this.#sortCountriesByPrefix) .filter((c) => { // done like this because typescript thinks that T.constructor is not a type of T but a plain function // https://stackoverflow.com/a/61444747/10645758 const t = this.constructor as { new (...args: ConstructorParameters<typeof Phone>): Phone } const tp = new t(phone, c.iso) return tp.valid }) if (potentials.length >= 1) { return potentials[0].iso } return 'XX' } /** * Sorts an array of countries by their phone number prefix. * @private * @param {Object} a - The first country object to compare. * @param {string} a.iso - The ISO 3166-1 alpha-2 code of the country. * @param {string} a.prefix - The phone number prefix of the country. * @param {Object} b - The second country object to compare. * @param {string} b.iso - The ISO 3166-1 alpha-2 code of the country. * @param {string} b.prefix - The phone number prefix of the country. * @returns {number} - A number indicating the order of the two countries in the sorted array. */ #sortCountriesByPrefix( a: { iso: string; prefix: string }, b: { iso: string; prefix: string } ): number { if (a.prefix.length === b.prefix.length) { const intA = parseInt(a.prefix) const intB = parseInt(b.prefix) if (intA === intB) { return a.iso.localeCompare(b.iso) } return intA > intB ? 1 : -1 } return a.prefix.length > b.prefix.length ? 1 : -1 } /** * Gets the type of the phone number as a string. * @private * @param {libPhoneNumber.PhoneNumber | undefined} parsed - The parsed phone number. * @returns {PhoneTypes} - The type of the phone number. * * @remarks * If the parsed phone number is not valid, the function returns 'INVALID'. * The function uses the libPhoneNumber library to determine the type of the phone number. */ #getPhoneNumberType(parsed: libPhoneNumber.PhoneNumber | undefined): PhoneTypes { if (!parsed) { return 'INVALID' } const type = this.#util.getNumberType(parsed) const typeValues = Object.values(libPhoneNumber.PhoneNumberType) const typeKeys = Object.keys(libPhoneNumber.PhoneNumberType) const typeIndex = typeValues.indexOf(type) return (typeKeys[typeIndex] as PhoneTypes) || 'INVALID' } #getCorrectedNANPCountry(): CountryOrUnknown { const ak = this.raw.substring(1, 4) if (areaCodeMap[ak] && 'string' === typeof areaCodeMap[ak]) { return areaCodeMap[ak] as Country } return 'XX' } /** * Returns an object representation of the phone number. * @returns {{ * phone: string; * country: string; * valid: boolean; * type: PhoneTypes; * mobile: boolean; * raw: string; * national: string; * international: string; * e164: string; * timezone: PossiblePhoneTimezone; * }} - An object representation of the phone number. */ public toObject() { return { /** * The phone number as a string stripped of all non-numeric characters. */ phone: this.#phone, /** * The country code of the phone number. * @remarks * In cases where the country was not recognized and could not be guessed from the phone number, `'XX'` is used. * @type {CountryOrUnknown} */ country: this.#country, /** * Whether the phone number uses a valid format for the parsed country or not. * @type {boolean} */ valid: this.valid, /** * The type of the phone number. * @type {PhoneTypes} */ type: this.type, /** * Whether the phone number is possibly a mobile number or not. * @type {boolean} */ mobile: this.mobile, /** * The phone number as a string stripped of all non-numeric characters. * @type {string} */ raw: this.raw, /** * The phone number as a string in the national format for the parsed country. * @type {string} */ national: this.national, /** * The phone number as a string in the international format for the parsed country. * @type {string} */ international: this.international, /** * The phone number as a string in the E.164 format. * @type {string} */ e164: this.e164, /** * The estimated timezone of the phone number based on the phone number's country. It can be either a `CountryTimezone` or `'UTC'`. */ timezone: this.timezone, } } /** * Returns a JSON representation of the phone number. * @returns {{ * phone: string; * country: string; * valid: boolean; * type: PhoneTypes; * mobile: boolean; * raw: string; * national: string; * international: string; * e164: string; * timezone: PossiblePhoneTimezone; * }} - A JSON-safe representation of the phone number. */ public toJSON() { return this.toObject() } /** * Returns the E.164 format of the phone number as a string. * @returns {string} - The E.164 format of the phone number. */ public toString() { return this.e164 } /** * Returns a stringified representation of the phone number. * @returns {string} - A string representation of the phone number. */ public inspect() { return `Phone { phone: ${this.#phone}, country: ${this.#country}, valid: ${this.valid === true ? 'true' : 'false'}, type: ${this.type}, mobile: ${this.mobile === true ? 'true' : 'false'}, raw: ${this.raw}, national: ${this.national}, international: ${this.international}, e164: ${this.e164}, timezone: ${this.timezone}, }` } /** * Serializes the phone object to an obfuscated string which can be used to recreate the phone object from the `Phone.deserialize` method. * @returns {string} - The serialized phone object. */ public serialize() { const hash = Buffer.from(JSON.stringify({ phone: this.raw, country: this.country })).toString( 'base64' ) return Buffer.from(JSON.stringify({ phone: this.raw, country: this.country, hash })).toString( 'base64' ) } /** * Creates a new phone object from a serialized phone object. * @param serialized The serialized phone object returned from the `Phone.serialize` method. * @returns A Phone instance with the same properties as the original phone object. * @throws An error if the serialized phone object is not valid. */ public static deserialize(serialized: string): Phone { let asJsonString: string try { asJsonString = Buffer.from(serialized, 'base64').toString('utf-8') } catch { throw new Error('Not a valid serialized phone object') } let asJson: { phone: string; country: CountryOrUnknown; hash: string } try { asJson = JSON.parse(asJsonString) } catch { throw new Error('Not a valid serialized phone object') } if (!asJson.phone || !asJson.country || !asJson.hash) { throw new Error('Not a valid serialized phone object') } const hash = Buffer.from( JSON.stringify({ phone: asJson.phone, country: asJson.country }) ).toString('base64') if (hash !== asJson.hash) { throw new Error('Not a valid serialized phone object') } return new Phone(asJson.phone, asJson.country === 'XX' ? undefined : asJson.country) } }