Last active
March 6, 2025 17:46
-
-
Save nyteshade/62349ab51887b55b228152c8a5061fc8 to your computer and use it in GitHub Desktop.
ECMAScript Intl CurrencyInfo Class
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Working with the ECMAScript {@link Intl} classes can be a trying and | |
* exhausting experience. Especially when working with currency strings | |
* for different locales. This class is designed to provide bi-directional | |
* formatting and detection of currency values in your code and leverages | |
* the built-in capabilities of most modern backend and browser based | |
* JavaScript environments. | |
* | |
* @example | |
* // Shorthand formatting to common combos like USD for 'en-US' | |
* CurrencyInfo.USD.format(123456789.123) => '$123,456,789.12' | |
* | |
* // Or CAD for 'fr-CA' | |
* CurrencyInfo.CAD.format(123456789.123) => '123 456 789,12 $' | |
* | |
* @example | |
* // Detection of locales by various input | |
* | |
* // Not enough discernible information available, could be anything | |
* CurrencyInfo.detect(1) => null | |
* | |
* // If there isn't enough information, provide an assumptive output | |
* // note that the score will still be 0 since nothing was detected | |
* CurrencyInfo.detect(1, { assume: CurrencyInfo.USD }) => | |
* { locale: 'en-US', score: 0, ... } | |
* CurrencyInfo.detect(1, { assume: { locale: 'en-US', currency: 'USD' }}) => | |
* { locale: 'en-US', score: 0, ... } | |
* | |
* // Or provide some currency string input and multiple checks will | |
* // be applied and a score derived. Things that will be checked | |
* // include grouping and decimal separators like commas vs. strings or | |
* // commas vs. periods, the position presence of the currency symbol | |
* // and ensuring that no more than one possible decimal separator is | |
* // present. The values to check are dynamically provided by the Intl | |
* // classes present in modern JavaScript runtime environments | |
* CurrencyInfo.detect('1 $') => { locale: 'fr-CA', score: 0.3, ... } | |
* CurrencyInfo.detect('123,00') => { locale: 'fr-CA', score: 0.6, ... } | |
* CurrencyInfo.detect('$1') => { locale: 'en-US', score: 0.3,... } | |
* | |
* @todo add detection and configuration of how many decimal places are | |
* present in formatted output. | |
* | |
* @class CurrencyInfo | |
*/ | |
export class CurrencyInfo { | |
/** | |
* Creates (and caches) or retrieves a cached instance that was previously | |
* created. New memory is only allocated if a cached instance could not be | |
* retrived using the supplied `currency` and `locale` values. | |
* | |
* @param {string} currency a currency code string such as `CAD` or `USD` | |
* @param {string} [locale = 'en-US'] a language and country code to help | |
* determine how currency should be string formatted. defaults to `en-US` | |
* if no value is supplied | |
* @param {boolean} [doNotThrow = false] if {@link true}, errors are | |
* returned instead of thrown, otherwise errors validating input or JS | |
* runtime capabilities are thrown during object creation. | |
* @returns {CurrencyInfo|Error} either a newly created and cached instance | |
* of {@link CurrencyInfo} or a previously created and cached instance. The | |
* function is pure in the sense that like input creates like output every | |
* time. An {@link Error} instance can be returned if `doNotThrow` is set to | |
* {@link true}. | |
* | |
* @throws {Error} if {@link Intl} is not present in your JavaScript runtime | |
* @throws {TypeError} if currency is not in the list returned by a call to | |
* {@link Intl.supportedValuesOf} ('currency') | |
* @throws {RangeError} if incorrect locale information is supplied to a | |
* call to {@link Intl.getCanonicalLocales} with the supplied `locale` | |
*/ | |
constructor(currency, locale = 'en-US', doNotThrow = false) { | |
// Fetch any cached value if one was previously created | |
const cached = this.constructor.cache(currency, locale) | |
// If we have a cached value, short-circuit and return it immediately | |
if (cached) | |
return cached | |
// Perform environmental and input validations | |
const errors = this.constructor.checkForInputErrors(currency, locale) | |
if (errors.length) { | |
if (doNotThrow) return errors[0]; else throw errors[0] | |
} | |
// Take the canonical locale value since we know it passed that test, or | |
// just use the locale if an empty array is returned but still doesn't | |
// throw an error [this last part is just a precaution, the expectation | |
// is that a value will always be returned] | |
locale = Intl.getCanonicalLocales(locale)?.[0] ?? locale | |
// Create a formatter based on locale and currency provided | |
const formatter = new Intl.NumberFormat(locale, { | |
style: 'currency', | |
currency, | |
currencyDisplay: 'symbol', | |
}) | |
// Split a large number into its semantic parts. This will return an | |
// array such as the following for "CAD", "fr-CA" | |
// [ | |
// { type: 'integer', value: '123' }, | |
// { type: 'group', value: ' ' }, | |
// { type: 'integer', value: '456' }, | |
// { type: 'group', value: ' ' }, | |
// { type: 'integer', value: '789' }, | |
// { type: 'decimal', value: ',' }, | |
// { type: 'fraction', value: '12' }, | |
// { type: 'literal', value: ' ' }, | |
// { type: 'currency', value: '$' } | |
// ] | |
const parts = formatter.formatToParts(123456789.123) | |
// Grab the first entry indicated as group and its associated string | |
const group = parts.find(p => p.type == 'group')?.value ?? ',' | |
// Grab the first entry indicated as decimal and its associated string | |
const decimal = parts.find(p => p.type == 'decimal')?.value ?? '.' | |
// Grab the first entry indicated at the currency type and its value | |
const symbol = parts.find(p => p.type == 'currency')?.value ?? '$' | |
// Shorthand for the static `escapeRegExp` function, allowing the class | |
// to be renamed without having to worry about that detail | |
const escapeRegExp = this.constructor.escapeRegExp | |
// Create an object that will generate a regular expression with the | |
// global flag set for each of the detected separator values. These | |
// will be properly escaped when the dynamic regular expression is | |
// generated. | |
// | |
// Note: a function is created rather a single instance because when | |
// an instance has any of its methods invoked, it takes on state. So | |
// the rSeparators create new regular expression instances with the | |
// separators strings escaped each time the are invoked | |
const regexSeparators = { | |
group: () => new RegExp(`[${escapeRegExp(group)}]`, 'g'), | |
symbol: () => new RegExp(symbol.length < 2 | |
? `[${escapeRegExp(symbol)}]` | |
: escapeRegExp(symbol), 'g' | |
), | |
decimal: () => new RegExp(`[${escapeRegExp(decimal)}]`, 'g') | |
} | |
/** | |
* This function removes any localized currency string formatting from | |
* the supplied value. While passing a {@link Number} is redundant and | |
* computationally wasteful, it is supported. All values passed in are | |
* first converted to String, the locale specific grouping, decimal and | |
* symbol string values are removed with the localized decimal being | |
* converted to decimal compatible period, all before being wrapped in | |
* a call to {@link Number}. | |
* | |
* This process will correctly return a valid {@link Nubmer} if the locale | |
* matches the input, but will result in a {@link NaN} value if the | |
* localizations are incorrect for the input in most cases. | |
* | |
* @param {*} numberOrString a number or string to strip of this specific | |
* locale's stylistic formatting | |
* @returns {number} the localized string's decimal equivalent or | |
* {@link NaN} if an error occurred while stripping the formatted value | |
* down. | |
*/ | |
function strip(numberOrString) { | |
return Number(String(numberOrString) | |
.replace(regexSeparators.group(), '') | |
.replace(regexSeparators.decimal(), '.') | |
.replace(regexSeparators.symbol(), '') | |
) | |
} | |
/** | |
* This custom function uses the supplied locale and currency values to | |
* to {@link strip} and {@link Intl.NumberFormat.format} the resulting | |
* stripped number. | |
* | |
* @param {*} numberOrString a value to format using this specific locale | |
* and currency values. | |
* @returns {string} a {@link String} that was created by first stripping | |
* any styling from the input using {@link strip} and then re-formatting | |
* the number using {@link Intl.NumberFormat} with this `currency` and | |
* `locale` values | |
*/ | |
function format(numberOrString) { | |
return formatter.format(this.strip(numberOrString)) | |
} | |
/** | |
* Calculates the position of the currency symbol, based on this locale | |
* and currency code, within the supplied string or formatted parts array. | |
* The resulting enum like response will be one of four strings, | |
* - **"leading"** symbol is first character | |
* - **"within"** symbol is present and neither the first nor the last | |
* character | |
* - **"trailing"** symbol is the last character | |
* - **"missing"** symbol is not present at all | |
* | |
* @example | |
* symbolPosition('$1.50') => "leading" | |
* symbolPosition('1,50 $') => "trailing" | |
* symbolPosition('1.50') => "missing" | |
* | |
* // usually "within" indicates a malformed input | |
* symbolPosition('1$50') => "within" | |
* | |
* // The position reflects the index of the array value with type | |
* // "currency" relative to the number of parts supplied. | |
* symbolPosition([ | |
* { type: 'currency', value: '$' }, | |
* { type: 'integer', value: '1' }, | |
* { type: 'decimal', value: '.' }, | |
* { type: 'fraction', value: '00' } | |
* ]) => "leading" | |
* | |
* @param {*|{type: string, value: string}[]} numberOrStringOrParts is | |
* either a value from which to make a {@link String} or it is an array | |
* returned from {@link Intl.NumberFormat.formatToParts} | |
* @returns {'leading'|'within'|'trailing'|'missing'} one of four possible | |
* string values based on this locale and currency's expected separator | |
* value's location in the input string or array. | |
*/ | |
function symbolPosition(numberOrStringOrParts) { | |
let parts = Array.isArray(numberOrStringOrParts) | |
? numberOrStringOrParts | |
: null; | |
let position = -1 | |
if (parts === numberOrStringOrParts) { | |
// Ensure the array contains only objects with both a type and value | |
// property and both of those values are truthy | |
parts = parts.filter(part => part?.type && part?.value) | |
// At this point, if the array contained no valid objects one would | |
// expect from Intl.NumberFormat.formatToParts() then -1 will be | |
// returned from the call to findIndex | |
position = parts.findIndex(p => p?.type == 'currency') | |
} | |
else { | |
// Convert the supplied non-array input to a string by wrapping it | |
// in a call to String(). All strings have a .length property and | |
// an .indexOf() function to identify the position of the currency | |
// symbol. | |
parts = String(numberOrStringOrParts) | |
// Locate the currency symbol or -1 in the list | |
position = parts.indexOf(symbol) | |
} | |
return (position === 0 | |
? 'leading' // currency symbol found as the first character | |
: position == parts.length - 1 | |
? 'trailing' // currency symbol found as the last character | |
: ~position // ensure we have a non-negative 1 value | |
? 'within' // then its someplace within the string | |
: 'missing' // otherwise it isn't present at all in the string | |
) | |
} | |
// Assign all the calculated values to properties of this new instance | |
// of CurrencyInfo. | |
Object.assign(this, { | |
// An object with markers specific to this currency and locale for | |
// the grouping, decimal and currency symbol values | |
separators: { | |
group, // example values might be "," or " " | |
decimal, // example values might be "." or "," | |
symbol, // example values might be "$" or "£" | |
}, | |
// An object with the same keys as separators except the values are | |
// a function that returns the separator as a global flagged, escaped, | |
// regular expression; e.g. new RegExp('[\\$]', 'g') | |
regex: regexSeparators, | |
// An object indicating the position of the currency symbol for numbers | |
// conforming to this locale, and a helper function to locate this | |
// currency and locale specific symbol in another string | |
symbol: { | |
position: symbolPosition(parts), | |
locator: symbolPosition, | |
}, | |
// The currency code for this CurrencyInfo instance | |
currency, | |
// The locale code for this CurrencyInfo instance | |
locale, | |
// A function to strip a formatted string conforming to this instance's | |
// locale and currency designations, back into a number | |
strip, | |
// Take a string or value to be converted into a string, strip its | |
// markers according to this locale and currency desingations, and then | |
// re-format it equally accordingly | |
format, | |
// The instance of Intl.NumberFormat created for this CurrencyInfo | |
// instance. | |
formatter, | |
}) | |
// Cache this newly created instance so that future requests for this | |
// currency and locale combination will simply return this created instance | |
this.constructor.cache(currency, locale, this) | |
} | |
/** | |
* Returns the class name so that if you pass an instance of the class | |
* to {@link String} function, you'll end up with something like | |
* `"[object CurrencyInfo]"` if the class name is CurrencyInfo. | |
* | |
* @type string | |
*/ | |
get [Symbol.toStringTag]() { | |
return this.constructor.name | |
} | |
/** | |
* Access to the cache of previously created {@link CurrencyInfo} instances | |
* that were created with the same `currency` and `locale` values. These are | |
* stored in an internal map using the string key of `${currency}-${locale}`. | |
* | |
* If a `value` is supplied, and the value is an instanceof | |
* {@link CurrencyInfo}, then it is added to the cache before being returned. | |
* If a `value` is supplied and its value is explicitly {@link null}, then | |
* any cached value of the calculated key is deleted from the cache. | |
* | |
* @param {string} currency a currency value type such as 'USD' | |
* @param {string} locale a locale value, defaults to 'en-US' | |
* @param {CurrencyInfo|null} value an optional value that can be stored in | |
* the cache using a key derived from the `currency` and `locale` parameters | |
* @returns {CurrencyInfo} a cached {@link CurrencyInfo} object instance | |
* or `undefined` if none has yet been cached for the supplied `currency` | |
* and `locale` combination. | |
*/ | |
static cache(currency, locale = 'en-US', value) { | |
currency = String(currency) | |
locale = String(locale) | |
const mapKey = Symbol.for('CurrencyInfoCache') | |
const dataKey = `${currency}-${locale}` | |
if (!this[mapKey]) | |
Object.defineProperty(this, mapKey, { | |
enumerable: false, | |
writable: false, | |
configurable: true, | |
value: new Map() | |
}) | |
if (value && value instanceof this) { | |
this[mapKey].set(dataKey, value) | |
} | |
else if (value === null && this[mapKey].has(dataKey)) { | |
this[mapKey].delete(dataKey) | |
} | |
return this[mapKey].get(dataKey) | |
} | |
/** | |
* Invokes {@link CurrencyInfo.validateRuntime}, followed by a call to | |
* {@link CurrencyInfo.validateCurrency} and then subsequently by a call to | |
* {@link CurrencyInfo.validateLocale}. The last two calls will be supplied | |
* the parameters passed to this function. | |
* | |
* @example | |
* CurrencyInfo.checkForInputErrors('bitcoin', 'en-US') => [ | |
* TypeError('Currency value bitcoin is not supported') | |
* ] | |
* | |
* @param {*} currency a currency value to validate | |
* @param {*} locale a locale value to validate | |
* @returns {error[]} any array of errors that occurred while testing | |
* for JS runtime capabilities, the supplied currency code and the | |
* supplied locale code. An empty array indicates no errors were detected | |
*/ | |
static checkForInputErrors(currency, locale) { | |
return [ | |
this.validateRuntime(), | |
this.validateCurrency(currency), | |
this.validateLocale(locale) | |
].filter(validation => validation instanceof Error) | |
} | |
/** | |
* This function makes a best effort to determine the currency and locale | |
* of a given input. It is intended to be used with input like '$1,234' or | |
* '1 343,23 $'. However, any value supplied will be wrapped in a call to | |
* the {@link String} function to convert it into a string for parsing. | |
* | |
* The default currency, language and country combinations will be attempted | |
* and information such as the position of the currency symbol or grouping | |
* identifiers will be used to find a best in class match. The highest | |
* scoring or first of tied highest scored matches will be returned if one | |
* could be detected. | |
* | |
* A successful result will return an object with the following shape: | |
* ``` | |
* { | |
* locale: string, // language-country code such as 'fr-CA' | |
* amount: number, // the input string stripped to a number, may | |
* // be `NaN` if it could not be properly | |
* // detected. Do not assume! | |
* formatted: string, // original input converted to number and | |
* // re-formatted using the detected match | |
* original: string, // original input wrapped in String() call | |
* currency: CurrencyInfo, // instance of `CurrencyInfo` | |
* score: number, // the scoring value as a fractional value of tests | |
* // conducted to determine a match | |
* } | |
* ``` | |
* | |
* @param {*} formatted any value for which an attempt to determine the | |
* language, country and locale will be made. This value will be wrapped | |
* in call to {@link String} converting it to a string. For some objects | |
* this may trigger an indirect call to {@link Object.valueOf} | |
* @param {string[]} options.currencies an array of currencies to test for; | |
* if this value is not supplied it defaults to `['USD', 'CAD']` | |
* @param {string[]} options.languages an array of language codes to test | |
* for; if this value is not supplied it defaults to `['en', 'es', 'fr']` | |
* @param {string[]} options.countries an array of country codes to test | |
* for; if this value is not supplied it defaults to `['US', 'CA']` | |
* @param {object|CurrencyInfo} options.assume defaults to `undefined`, but | |
* can be either a plain object with `locale` and `currency` string | |
* properties, or an instanceof {@link CurrencyInfo} | |
* @param {string} options.assume.locale a language and country code to | |
* use if a locale could not be determined; defaults to undefined | |
* @param {string} options.assume.currency a currency code to default to | |
* if a currency could not be determined; defaults to undefined | |
* @returns {object} if the currency information can be detected from | |
* the supplied info and object meeting the specification described above | |
* will be returned. If no currency information could be detected, and | |
* valid values for `assume` are provided, that currency information will | |
* be used, but the score will be 0. If no information could be determined | |
* and no assume values are provided, `null` will be returned. | |
*/ | |
static detect(formatted, options = {}) { | |
// Ensure formatted is a string. This will work for any input value | |
formatted = String(formatted) | |
// Extract the values to use from the supplied options object and | |
// inject default values if they were not manually supplied by the | |
// caller. | |
let { | |
currencies = ['USD', 'CAD'], | |
languages = ['en', 'es', 'fr'], | |
countries = ['US', 'CA'], | |
assume = undefined, // { locale: 'en-US', currency: 'USD' } | |
} = Object(options) | |
// Generate locales from the combination of languages and countries | |
// supplied in the options above | |
const locales = countries.reduce((acc, country) => { | |
for (const language of languages) { | |
acc.push(`${language}-${country}`) | |
} | |
return acc | |
}, []); | |
// Convert all of our locales and supplied or default currency codes | |
// into CurrencyInfo instances. These will be cached instances if they've | |
// already been created before. | |
const currencyData = currencies.reduce( | |
(acc, currency) => { | |
locales | |
/* Converts each locale, currency combo into a CurrencyInfo */ | |
.map(locale => CurrencyInfo.get(currency, locale)) | |
/* Adds each CurrencyInfo instance to the locale's array */ | |
.forEach(currencyInfo => acc[currencyInfo.locale].push(currencyInfo)); | |
return acc | |
}, | |
// Starting value will be an object with an empty array for each locale | |
locales.reduce((acc, locale) => { acc[locale] = []; return acc }, {}) | |
) | |
// Create scoreboard for checks made on each combination | |
let scoreboard = {} | |
// Let's walk our list of locale and currency info object lists | |
for (const [locale, currencies] of Object.entries(currencyData)) { | |
for (const info of currencies) { | |
const { group, decimal, symbol } = info.regex | |
const amount = info.strip(formatted) | |
const reformatted = info.format(amount) | |
let score = 0; | |
// If this CurrencyInfo stripped amount number is NaN, this locale | |
// currency combo is not a match, move on | |
if (isNaN(amount)) { | |
continue; | |
} | |
// If the reformatted string perfectly matches the original, we have | |
// a 100% score match. Simply return it now and skip the loops. | |
if (reformatted == formatted) { | |
return { | |
locale, | |
amount, | |
formatted: reformatted, | |
currencyInfo: info, | |
score: 1.0 | |
} | |
} | |
// Count the grouping markers and decimal markers | |
const numberOfGroupSeparators = formatted.match(group())?.length ?? 0 | |
const numberOfDecimalSeparators = formatted.match(decimal())?.length ?? 0 | |
// If we have more than one count of a decimal marker, we have invalid | |
// data and this locale/currency combo is not a match; move on | |
if (numberOfDecimalSeparators > 1) { | |
continue | |
} | |
// Count the number of times the curency symbol appeared | |
const numberOfSymbols = formatted.match(symbol())?.length ?? 0 | |
// Check the position of the currency marker in the original formatted | |
// string and the reformatted string from a stripped number | |
const iSymbolPosition = info.symbol.locator(formatted) | |
const oSymbolPosition = info.symbol.locator(reformatted) | |
// If we have multiple counts of grouping separators, we likely have | |
// a match, increase our score | |
if (numberOfGroupSeparators) { | |
score++ | |
// If there is more than one, its more likey a comaptible match | |
// so we should increase the score here too | |
if (numberOfGroupSeparators > 1) score++ | |
} | |
// Having a matching decimal separator score earns a point | |
if (numberOfDecimalSeparators) { | |
score++ | |
// If there is exactly 1 decimal separator, add a point | |
if (numberOfDecimalSeparators == 1) score++ | |
} | |
// If we have at least one currency symbol marker that matches the | |
// locale/currency configuration, add a point | |
if (numberOfSymbols > 0) { | |
score++ | |
// We get another point if the position of the currency marker is | |
// the same in formatted and reformatted values. | |
if (iSymbolPosition == oSymbolPosition) | |
score++ | |
// However if the symbol positions are not equal, leading vs trailing | |
// or leading vs missing, for example, we likey do not have valid | |
// match, so let's deduct a point | |
else | |
score-- | |
} | |
// Clamp the calculated score to number from 0 to 6. Just to be | |
// consistent with expectations | |
score = Math.max(0, Math.min(6, score)) | |
// Store the results in the scoreboard and move on to the next | |
// combination of currency, language and locale | |
scoreboard[locale] = { | |
score: score / 6, | |
currencyInfo: info | |
} | |
} | |
} | |
// If we are here, we didn't find a perfect match. So lets start the | |
// scoreboard comparisons. We will reduce them down to a single result | |
let result = Object.entries(scoreboard).reduce( | |
(acc, [locale, {score, currencyInfo}]) => { | |
// If we have a valid .locale property and score that is greater than | |
// zero OR if we have a score already and that score is less than | |
// the current score, lets take that value | |
if ((!acc.locale && score > 0) || acc.score < score) { | |
acc.locale = locale | |
acc.amount = currencyInfo.strip(formatted) | |
acc.formatted = currencyInfo.format(acc.amount) | |
acc.score = score | |
acc.currencyInfo = currencyInfo | |
} | |
return acc | |
}, | |
// The starting value will be a result object with a null or undefined | |
// value for the .locale, .amount, .formatted, and .currencyInfo | |
// properties. These will be filled with the highest scoring values | |
// in the reduction process. | |
{ | |
locale: null, | |
amount: undefined, | |
formatted: null, | |
original: formatted, | |
currencyInfo: null, | |
score: 0 | |
} | |
) | |
// If we ended up with a reduced value that has no assigned .locale | |
// property, we didn't find any scoring matches. Check to see if we | |
// have provided an assumption object. This can be an object with | |
// a locale AND currenc code or a CurrencyInfo instance which also | |
// has these properties. | |
if (!result.locale && assume && typeof assume === 'object') { | |
const { locale: assumedLocale, currency: assumedCurency } = assume | |
// If everything checks out, use a matching CurrencyInfo instance | |
// to adjust our reduced value to the assumption result values. Note | |
// this process will still result in a score of 0 but will have | |
// values formatted and stripped according to the provided assumption's | |
// localizations. This may result in NaN amounts and unexpected formatted | |
// string values. | |
if (assumedLocale && assumedCurency) { | |
result.locale = assumedLocale | |
result.currency = assumedCurency | |
result.currencyInfo = CurrencyInfo.get(assumedCurency, assumedLocale) | |
result.amount = result.currencyInfo.strip(formatted) | |
result.formatted = result.currencyInfo.format(result.amount) | |
} | |
} | |
// Return the reduced result only if it has a truthy .locale property, | |
// othewise return null specifically. | |
return result.locale ? result : null | |
} | |
/** | |
* A quick function to ensure we can escape string input to dynamically | |
* created {@link RegExp} class instances. This will escape any provided | |
* value, which is ensured to be converted to a {@link String} if it is | |
* not already a string. | |
* | |
* So values like `.` or `$` or `^` which all possess special meanings | |
* in regular expressions will be escaped in the supplied string. | |
* | |
* @param {*} string a value that will be wrapped in the {@link String} | |
* function. Values that are already strings will remain as such, but | |
* values that are not will be converted into one. | |
* @returns {string} a regular expression safe string | |
*/ | |
static escapeRegExp(string) { | |
return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
} | |
/** | |
* Shorthand alias function that will create a new instance of | |
* {@link CurrencyInfo} or retrieve an already cached version. This function | |
* provides semantic readability but is identical to calling this class with | |
* `new` keyword. | |
* | |
* @param {string} currency the type of currency such as `CAD` or `USD` | |
* @param {string} [locale = 'en-US'] a language and country code to help | |
* determine how currency should be string formatted. defaults to `en-US` | |
* if no value is supplied | |
* @param {boolean} [doNotThrow = false] if {@link true}, errors are | |
* returned instead of thrown, otherwise errors validating input or JS | |
* runtime capabilities are thrown during object creation. | |
* @returns {CurrencyInfo|Error} either a newly created and cached instance | |
* of {@link CurrencyInfo} or a previously created and cached instance. The | |
* function is pure in the sense that like input creates like output every | |
* time. An {@link Error} instance can be returned if `doNotThrow` is set to | |
* {@link true}. | |
* | |
* @throws {Error} if {@link Intl} is not present in your JavaScript runtime | |
* @throws {TypeError} if currency is not in the list returned by a call to | |
* {@link Intl.supportedValuesOf} ('currency') | |
* @throws {RangeError} if incorrect locale information is supplied to a | |
* call to {@link Intl.getCanonicalLocales} with the supplied `locale` */ | |
static get(currency, locale = 'en-US', doNotThrow = false) { | |
return new this(currency, locale) | |
} | |
/** | |
* A semantic alternative to `value instanceof CurrencyInfo` that will | |
* dynamically check if the supplied value is an instance of this class | |
* | |
* @param {*} value any value to be tested using `instanceof CurrencyInfo` | |
* @returns {@link true} if the supplied value is an instance of this class | |
* or {@link false} otherwise | |
*/ | |
static isCurrencyInfo(value) { | |
return value instanceof this | |
} | |
/** | |
* Checks with the runtime's knowledge of international currencies. These | |
* can be found by calling `Intl.supportedValuesOf('currency')` which | |
* returns an {@link Array} of supported currency {@link String} codes. | |
* | |
* @param {*} currency a value to check as a valid {@link Intl} currency | |
* code such as `USD` | |
* @returns {true|TypeError} an instance of {@link TypeError} if the currency | |
* is an unknown code, or {@link true} if it is properly recognized in a | |
* the results of a call to {@link Intl.supportedValuesOf} with `currency` | |
* as a parameter. | |
*/ | |
static validateCurrency(currency) { | |
if (!Intl.supportedValuesOf('currency').includes(currency)) { | |
return new TypeError(`Currency value ${currency} is not supported`) | |
} | |
return true | |
} | |
/** | |
* Checks with the runtime's knowledge of canonical locales. The ECMAScript | |
* {@link Intl} library does not, as of the time of this writing, provide a | |
* dynamic list of known `language-Country` codes, however it will throw an | |
* error if an invalid input in a call to {@link Intl.getCanonicalLocales} | |
* is performed. So this function relies on that knowledge and captures the | |
* error if one is raised. Otherwise, {@link true} will be returned if no | |
* error is thrown in the process of checking. | |
* | |
* @param {*} locale a value to check as a valid {@link Intl} locale | |
* code such as `en-US` | |
* @returns {true|RangeError} an instance of {@link RangeError} if the locale | |
* is an unknown code, or {@link true} if it is properly recognized in a | |
* the results of a call to {@link Intl.getCanonicalLocales}. | |
*/ | |
static validateLocale(locale) { | |
try { | |
locale = Intl.getCanonicalLocales(locale) | |
} | |
catch (error) { | |
return error | |
} | |
return true | |
} | |
/** | |
* Checks the runtime for {@link Intl} capabilities and returns {@link true} | |
* or an instance of {@link Error} the needed functions and classes are | |
* not present. | |
* | |
* @returns {true|Error} {@link true} if {@link Intl} and its needed | |
* classes and functions are present, or an {@link Error} indicating | |
* the JavaScript runtime this function is executed in does not have | |
* sufficient ECMAScript internationalization capabillities | |
*/ | |
static validateRuntime() { | |
if ( | |
typeof globalThis == 'undefined' || | |
typeof globalThis.Intl != 'object' || | |
typeof Intl.supportedValuesOf != 'function' || | |
typeof Intl.getCanonicalLocales != 'function' || | |
typeof Intl.NumberFormat != 'function' | |
) { | |
return new Error(`Your JavaScript runtime does not support Intl`) | |
} | |
return true | |
} | |
/** @returns alias for `CurrencyInfo.get('USD', 'en-US')` */ | |
static get USD() { return this.get('USD', 'en-US') } | |
/** @returns alias for `CurrencyInfo.get('CAD', 'fr-CA')` */ | |
static get CAD() { return this.get('CAD', 'fr-CA') } | |
/** @returns alias for `CurrencyInfo.get('CAD', 'en-CA')` */ | |
static get enCAD() { return this.get('CAD', 'en-CA') } | |
/** @returns alias for `CurrencyInfo.get('CAD', 'fr-CA')` */ | |
static get frCAD() { return this.CAD } | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { CurrencyInfo } from './CurrencyInfo'; | |
describe('CurrencyInfo', () => { | |
describe('constructor and get', () => { | |
it('should create a new instance', () => { | |
const info = new CurrencyInfo('USD', 'en-US'); | |
expect(info).toBeInstanceOf(CurrencyInfo); | |
}); | |
it('should return cached instance', () => { | |
const info1 = CurrencyInfo.get('USD', 'en-US'); | |
const info2 = CurrencyInfo.get('USD', 'en-US'); | |
expect(info1).toBe(info2); | |
}); | |
it('should throw error for invalid currency', () => { | |
expect(() => new CurrencyInfo('INVALID', 'en-US')).toThrow(TypeError); | |
}); | |
it('should throw error for invalid locale', () => { | |
expect(() => new CurrencyInfo('USD', 'invalid-LOCALE')).toThrow(RangeError); | |
}); | |
it('should return error instead of throwing when doNotThrow is true', () => { | |
const result = new CurrencyInfo('INVALID', 'en-US', true); | |
expect(result).toBeInstanceOf(Error); | |
}); | |
}); | |
describe('format and strip', () => { | |
const usd = CurrencyInfo.get('USD', 'en-US'); | |
const cad = CurrencyInfo.get('CAD', 'fr-CA'); | |
it('should format numbers correctly', () => { | |
expect(usd.format(1234.56)).toBe('$1,234.56'); | |
expect(cad.format(1234.56)).toBe('1 234,56 $'); | |
}); | |
it('should strip formatted strings correctly', () => { | |
expect(usd.strip('$1,234.56')).toBe(1234.56); | |
expect(cad.strip('1 234,56 $')).toBe(1234.56); | |
}); | |
}); | |
describe('symbolPosition', () => { | |
const usd = CurrencyInfo.get('USD', 'en-US'); | |
it('should correctly identify symbol positions', () => { | |
expect(usd.symbol.locator('$100')).toBe('leading'); | |
expect(usd.symbol.locator('100$')).toBe('trailing'); | |
expect(usd.symbol.locator('1$00')).toBe('within'); | |
expect(usd.symbol.locator('100')).toBe('missing'); | |
}); | |
it('should work with formatToParts array', () => { | |
const parts = [ | |
{ type: 'currency', value: '$' }, | |
{ type: 'integer', value: '100' }, | |
]; | |
expect(usd.symbol.locator(parts)).toBe('leading'); | |
}); | |
}); | |
describe('static methods', () => { | |
it('should escape RegExp special characters', () => { | |
expect(CurrencyInfo.escapeRegExp('$.')).toBe('\\$\\.'); | |
}); | |
it('should validate currency codes', () => { | |
expect(CurrencyInfo.validateCurrency('USD')).toBe(true); | |
expect(CurrencyInfo.validateCurrency('INVALID')).toBeInstanceOf(TypeError); | |
}); | |
it('should validate locales', () => { | |
expect(CurrencyInfo.validateLocale('en-US')).toBe(true); | |
expect(CurrencyInfo.validateLocale('invalid-LOCALE')).toBeInstanceOf(RangeError); | |
}); | |
it('should validate runtime capabilities', () => { | |
expect(CurrencyInfo.validateRuntime()).toBe(true); | |
}); | |
it('should check if value is CurrencyInfo instance', () => { | |
expect(CurrencyInfo.isCurrencyInfo(new CurrencyInfo('USD', 'en-US'))).toBe(true); | |
expect(CurrencyInfo.isCurrencyInfo({})).toBe(false); | |
}); | |
}); | |
describe('detect', () => { | |
it('should detect USD', () => { | |
const result = CurrencyInfo.detect('$1,234.56'); | |
expect(result.locale).toBe('en-US'); | |
expect(result.currencyInfo.currency).toBe('USD'); | |
expect(result.score).toBeGreaterThan(0); | |
}); | |
it('should detect CAD', () => { | |
const result = CurrencyInfo.detect('1 234,56 $'); | |
expect(result.locale).toBe('fr-CA'); | |
expect(result.currencyInfo.currency).toBe('CAD'); | |
expect(result.score).toBeGreaterThan(0); | |
}); | |
it('should return null for undetectable input', () => { | |
expect(CurrencyInfo.detect('abc')).toBeNull(); | |
}); | |
it('should use assume option when detection fails', () => { | |
const result = CurrencyInfo.detect('123', { assume: { locale: 'en-US', currency: 'USD' } }); | |
expect(result.locale).toBe('en-US'); | |
expect(result.currencyInfo.currency).toBe('USD'); | |
expect(result.score).toBe(0); | |
}); | |
it('should handle custom currencies and locales', () => { | |
const result = CurrencyInfo.detect('£1,234.56', { | |
currencies: ['GBP'], | |
languages: ['en'], | |
countries: ['GB'] | |
}); | |
expect(result.locale).toBe('en-GB'); | |
expect(result.currencyInfo.currency).toBe('GBP'); | |
}); | |
}); | |
describe('static getters', () => { | |
it('should return correct instances for USD and CAD', () => { | |
expect(CurrencyInfo.USD.currency).toBe('USD'); | |
expect(CurrencyInfo.USD.locale).toBe('en-US'); | |
expect(CurrencyInfo.CAD.currency).toBe('CAD'); | |
expect(CurrencyInfo.CAD.locale).toBe('fr-CA'); | |
expect(CurrencyInfo.enCAD.locale).toBe('en-CA'); | |
expect(CurrencyInfo.frCAD).toBe(CurrencyInfo.CAD); | |
}); | |
}); | |
describe('Symbol.toStringTag', () => { | |
it('should return correct string representation', () => { | |
const info = new CurrencyInfo('USD', 'en-US'); | |
expect(Object.prototype.toString.call(info)).toBe('[object CurrencyInfo]'); | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"jest": { | |
"collectCoverage": true, | |
"coverageReporters": ["text", "lcov"], | |
"coverageThreshold": { | |
"global": { | |
"branches": 80, | |
"functions": 80, | |
"lines": 80, | |
"statements": 80 | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This gist provides a convenient way to leverage the JavaScript runtime's Intl.NumberFormat class for a given locale and currency in both directions; e.g. formatted strings or base numbers that need localized formatting.
This gist is licensed as MIT and for unlimited but not exclusive use by Intuit, Inc.; use freely