/** * Valid values that can be converted to exponential notation. */ export type ValidValue = string | number | bigint; interface BaseOptions { /** * The decimal point character to use. Defaults to ".". */ decimalPoint?: string; /** * The grouping symbol to use. Defaults to ",". */ groupingSymbol?: string; /** * The exponent character to use. Defaults to "e". */ exponentDelimiter?: string; } /** * Options to use when converting a number to exponential notation. */ export type NumberToExponentialOptions = BaseOptions; /** * Options to use when converting exponential notation to a number string. */ export interface ExponentialToNumberStringOptions extends BaseOptions { /** * The maximum length to represent the return value else the input will be preserved. */ maxLength?: number; } /** * Default decimal point character. */ const DEFAULT_DECIMAL_POINT = '.' as const; /** * Default grouping symbol. */ const DEFAULT_GROUPING_SYMBOL = ',' as const; /** * Default exponent character. */ const DEFAULT_EXPONENT_DELIMITER = 'e' as const; /** * Default positive symbol. */ const DEFAULT_POSITIVE_SYMBOL = '+' as const; /** * Default negative symbol. */ const DEFAULT_NEGATIVE_SYMBOL = '-' as const; /** * Forces a value to have a decimal point. * @param value - The value to force a decimal point on. * @param decimalPoint - The decimal point character to use. * @returns string The value with a decimal point. */ const forceDecimalPoint = (value: string, decimalPoint: string) => ( value.includes(decimalPoint) ? value : `${value}${decimalPoint}0` ); /** * Error thrown when a value is not in exponential notation. */ export class InvalidFormatError extends TypeError { constructor(value: string) { super(`Value must be in exponential notation. Received: ${value}`); } } /** * Error thrown when a value is not a string, number, or bigint. */ export class InvalidValueTypeError extends TypeError { constructor(value: unknown) { super(`Value must be a string, number, or bigint. Received: ${typeof value}`); } } /** * Forces a number to have a sign. * @param value - The value to force a sign on. * @returns string The value with a sign. */ const forceNumberSign = (value: bigint) => { const isExponentNegative = value < 0; const exponentValueAbs = isExponentNegative ? -value : value; const exponentSign = isExponentNegative ? DEFAULT_NEGATIVE_SYMBOL : DEFAULT_POSITIVE_SYMBOL; return `${exponentSign}${exponentValueAbs}`; }; interface ExponentialComponents { integer: string; fractional: string; exponent: string; } /** * Extracts the integer, fractional, and exponent components of a string in exponential notation. * @param value - The string value to extract components from. * @param options - Options to use when extracting components. * @returns ExponentialComponents The extracted components. */ export const extractExponentialComponents = ( value: string, options = {} as BaseOptions, ): ExponentialComponents => { const { decimalPoint = DEFAULT_DECIMAL_POINT, groupingSymbol = DEFAULT_GROUPING_SYMBOL, exponentDelimiter = DEFAULT_EXPONENT_DELIMITER, } = options; const valueWithoutGroupingSymbols = value.replace(new RegExp(`${groupingSymbol}`, 'g'), ''); const exponentDelimiterIndex = valueWithoutGroupingSymbols.indexOf(exponentDelimiter); if (exponentDelimiterIndex < 0) { // We force the value to have decimal point so that we can extract the integer and fractional // components. const stringValueWithDecimal = forceDecimalPoint(valueWithoutGroupingSymbols, decimalPoint); const [integerRaw, fractionalRaw] = stringValueWithDecimal.split(decimalPoint); const integer = integerRaw.replace(/^0+/g, ''); const fractional = fractionalRaw.replace(/0+$/g, ''); const exponentValue = BigInt(integer.length - 1); return { integer, exponent: forceNumberSign(exponentValue), fractional, }; } if (exponentDelimiterIndex !== valueWithoutGroupingSymbols.lastIndexOf(exponentDelimiter)) { throw new InvalidFormatError(value); } const [base, exponentRaw] = valueWithoutGroupingSymbols.split(exponentDelimiter); const [integerRaw, fractionalRaw = ''] = base.split(decimalPoint); const integerWithoutZeroes = integerRaw.replace(/^0+/g, ''); const integer = integerWithoutZeroes[0] ?? '0'; const extraIntegerDigits = integerWithoutZeroes.slice(1); const fractional = `${extraIntegerDigits}${fractionalRaw.replace(/0+$/g, '')}`; const exponentValue = BigInt(exponentRaw) + BigInt(extraIntegerDigits.length); return { integer, exponent: forceNumberSign(exponentValue), fractional, }; }; /** * Converts a numeric value to a string in exponential notation. Supports numbers of all types. * @param value - The value to convert. * @param options - Options to use when extracting components. * @returns string The value in exponential notation. */ export const numberToExponential = ( value: ValidValue, options = {} as NumberToExponentialOptions, ): string => { const valueRaw = value as unknown; if (typeof valueRaw === 'bigint' || typeof valueRaw === 'number') { return numberToExponential(valueRaw.toString(), options); } if (typeof valueRaw !== 'string') { throw new InvalidValueTypeError(valueRaw); } if (valueRaw.startsWith(DEFAULT_NEGATIVE_SYMBOL)) { return `${DEFAULT_NEGATIVE_SYMBOL}${numberToExponential(valueRaw.slice(DEFAULT_NEGATIVE_SYMBOL.length), options)}}`; } const { decimalPoint = DEFAULT_DECIMAL_POINT, groupingSymbol = DEFAULT_GROUPING_SYMBOL, exponentDelimiter = DEFAULT_EXPONENT_DELIMITER, } = options; // Remove invalid characters. // We also remove the grouping symbols in case they come from human input. const stringValue = valueRaw .replace(new RegExp(`${groupingSymbol}`, 'g'), '') .toLowerCase() .replace(/\s/g, ''); const { integer, fractional, exponent, } = extractExponentialComponents(stringValue, options); const significantDigits = `${integer}${fractional}`; if (significantDigits.length === 0) { // We copy the behavior from `Number.prototype.toExponential` here. return `0${exponentDelimiter}${DEFAULT_POSITIVE_SYMBOL}0`; } const significandInteger = significantDigits[0]; const significandFractional = significantDigits.slice(1).replace(/0+$/g, ''); if (significandFractional.length === 0) { return `${significandInteger}${exponentDelimiter}${exponent}`; } return `${significandInteger}${decimalPoint}${significandFractional}${exponentDelimiter}${exponent}`; }; export const exponentialToNumberString = ( exp: string, options = {} as ExponentialToNumberStringOptions, ) => { const { decimalPoint = DEFAULT_DECIMAL_POINT, maxLength = Number.MAX_SAFE_INTEGER, } = options; const { integer, fractional, exponent } = extractExponentialComponents(exp, options); const currentDecimalPointIndex = integer.length; const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent); if (newDecimalPointIndex > maxLength) { // Integer part overflow // // Important to bail out as early as possible. throw new RangeError('Could not represent number in string form.'); } const fractionalDigits = fractional.length > 0 ? fractional : '0'; const significantDigits = `${integer}${fractionalDigits}`; if (significantDigits.length > maxLength) { // Digits overflow // // Should we choose to truncate instead of throw an exception? // Quite tricky when only the fractional part overflows. throw new RangeError('Could not represent number in string form.'); } let newInteger: string; try { newInteger = significantDigits.slice(0, newDecimalPointIndex) .padEnd(newDecimalPointIndex, '0'); } catch { // Error in padEnd(), shadow it. throw new RangeError('Could not represent number in string form.'); } const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/g, ''); if (newFractional.length === 0) { return newInteger; } if (newInteger.length + decimalPoint.length + newFractional.length > maxLength) { // Fractional part overflow // // Final length check. throw new RangeError('Could not represent number in string form.'); } return `${newInteger}${decimalPoint}${newFractional}`; };