diff --git a/README.md b/README.md index 57b0230..c3e8fda 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,22 @@ Get the name of a number, even if it's stupidly big. ## References * [How high can you count?](http://www.isthe.com/chongo/tech/math/number/howhigh.html) + +## Features + +* Stringify/parse numbers and names in American/British short count (e.g. "million", "billion", "trillion"), or European + (e.g. "million", "milliard", "billion", "billiard", "trillion", "trilliard"). +* Support for exponential notation, even if in non-standard form (e.g. `123.45e+5`). +* Support for negative numbers. + +See [TODO.md](TODO.md) for a list of features that are planned for implementation. + +## Limitations + +* Can only stringify and parse numbers and names that resolve to integer values. +* Exponents `x` in values are limited to `Number.MAX_SAFE_INTEGER >~ x >~ Number.MIN_SAFE_INTEGER`. + Values may exceed extremes such as `Number.MAX_SAFE_INTEGER` and `Number.EPSILON` in which loss of + precision may occur. +* No support for arbitrary number names such as "googol". +* No support for fractional number names such as "half", "quarter", "third", "tenth", etc. +* Supports only native types (`bigint`, `string`, `number`). diff --git a/TODO.md b/TODO.md index 1d0ae73..75356fa 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,3 @@ - [ ] Ordinals -- [ ] Fractions -- [ ] Other locales (long count, languages, etc.) +- [ ] ~~Fractions~~ +- [X] Other locales (long count, languages, etc.) diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index b63edf1..af54815 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -1,7 +1,9 @@ +type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; + /** * Group digits. */ -type GroupDigits = string; +export type GroupDigits = `${Digit}${Digit}${Digit}`; /** * Group place. @@ -102,7 +104,7 @@ export interface NumberNameSystem { * Parses groups from a string. * @param tokens - The string to parse groups from. * @see {NumberNameSystem.stringifyGroups} - * @returns Group[] The parsed groups. + * @returns ParseResult The parsed groups as well as an indicator if the number is Boolean. */ parseGroups: (tokens: string[]) => ParseResult; /** @@ -115,6 +117,25 @@ export interface NumberNameSystem { combineGroups: (groups: Group[], negative: boolean) => string; } +/** + * Allowed value type for {@link stringify}. + */ +export type AllowedValue = string | number | bigint; + +/** + * Array of allowed types for {@link parse}. + */ +export const ALLOWED_PARSE_RESULT_TYPES = [ + 'string', + 'number', + 'bigint', +] as const; + +/** + * Allowed type for {@link parse}. + */ +export type ParseResultType = typeof ALLOWED_PARSE_RESULT_TYPES[number]; + /** * Error thrown when an invalid token is encountered. */ @@ -124,6 +145,11 @@ export class InvalidTokenError extends Error { } } +/** + * Gets the maximum value from a list of bigints. + * @param b - The bigints to get the maximum value from. + * @returns bigint The maximum value. + */ export const bigIntMax = (...b: bigint[]) => b.reduce( (previousMax, current) => { if (typeof previousMax === 'undefined') { @@ -134,6 +160,11 @@ export const bigIntMax = (...b: bigint[]) => b.reduce( undefined as bigint | undefined, ); +/** + * Gets the minimum value from a list of bigints. + * @param b - The bigints to get the minimum value from. + * @returns bigint The minimum value. + */ export const bigIntMin = (...b: bigint[]) => b.reduce( (previousMin, current) => { if (typeof previousMin === 'undefined') { diff --git a/packages/core/src/converter.ts b/packages/core/src/converter.ts index e9e5dca..b1d9880 100644 --- a/packages/core/src/converter.ts +++ b/packages/core/src/converter.ts @@ -1,5 +1,10 @@ import { enUS } from './systems'; -import { NumberNameSystem } from './common'; +import { + ALLOWED_PARSE_RESULT_TYPES, + AllowedValue, + NumberNameSystem, + ParseResultType, +} from './common'; import { exponentialToNumberString, extractExponentialComponents, numberToExponential } from './exponent'; /** @@ -12,25 +17,6 @@ const NEGATIVE_SYMBOL = '-' as const; */ const EXPONENT_DELIMITER = 'e' as const; -/** - * Allowed value type for {@link stringify}. - */ -export type AllowedValue = string | number | bigint; - -/** - * Array of allowed types for {@link parse}. - */ -const ALLOWED_PARSE_RESULT_TYPES = [ - 'string', - 'number', - 'bigint', -] as const; - -/** - * Allowed type for {@link parse}. - */ -type ParseResult = typeof ALLOWED_PARSE_RESULT_TYPES[number]; - /** * Options to use when converting a value to a string. */ @@ -104,7 +90,7 @@ export interface ParseOptions { /** * The type to parse the value as. */ - type?: ParseResult; + type?: ParseResultType; } /** @@ -115,7 +101,7 @@ export interface ParseOptions { */ export const parse = (value: string, options = {} as ParseOptions) => { const { system = enUS.shortCount, type: typeRaw = 'string' } = options; - const type = typeRaw.trim().toLowerCase() as ParseResult; + const type = typeRaw.trim().toLowerCase() as ParseResultType; if (!((ALLOWED_PARSE_RESULT_TYPES as unknown as string[]).includes(type))) { throw new TypeError(`Return type must be a string, number, or bigint. Received: ${type}`); diff --git a/packages/core/src/exponent.ts b/packages/core/src/exponent.ts index 0a625a1..0cf4bbd 100644 --- a/packages/core/src/exponent.ts +++ b/packages/core/src/exponent.ts @@ -1,7 +1,4 @@ -/** - * Valid values that can be converted to exponential notation. - */ -export type ValidValue = string | number | bigint; +import { AllowedValue } from './common'; interface BaseOptions { /** @@ -158,13 +155,13 @@ export const extractExponentialComponents = ( }; /** - * Converts a numeric value to a string in exponential notation. Supports numbers of all types. + * Converts a numeric value to a string in exponential notation. * @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, + value: AllowedValue, options = {} as NumberToExponentialOptions, ): string => { const valueRaw = value as unknown; @@ -216,6 +213,12 @@ export const numberToExponential = ( return `${significandInteger}${decimalPoint}${significandFractional}${exponentDelimiter}${exponent}`; }; +/** + * Converts a string in exponential notation (e.g. 1e+2) to a number string (e.g. 100). + * @param exp - The number in exponential notation to convert. + * @param options - Options to use when converting number strings. + * @returns string The number string. + */ export const exponentialToNumberString = ( exp: string, options = {} as ExponentialToNumberStringOptions, diff --git a/packages/core/src/systems/en-UK/long-count/parse.ts b/packages/core/src/systems/en-UK/long-count/parse.ts index 97f91be..585751d 100644 --- a/packages/core/src/systems/en-UK/long-count/parse.ts +++ b/packages/core/src/systems/en-UK/long-count/parse.ts @@ -2,7 +2,7 @@ import { bigIntMax, bigIntMin, Group, GROUP_DIGITS_INDEX, - GROUP_PLACE_INDEX, + GROUP_PLACE_INDEX, GroupDigits, InvalidTokenError, } from '../../../common'; import { @@ -269,14 +269,14 @@ const parseThousand = (acc: ParserState, token: string): ParserState => { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } } else if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((t) => t === acc.lastToken); if (tens > -1) { lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` - ); + ) as GroupDigits; } } @@ -294,7 +294,7 @@ const parseThousand = (acc: ParserState, token: string): ParserState => { const parseHundred = (acc: ParserState): ParserState => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; const hundreds = ONES.findIndex((o) => o === acc.lastToken); - lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}`; + lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}` as GroupDigits; return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], @@ -308,7 +308,7 @@ const parseFinal = (acc: ParserState): ParserState => { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } // We assume last token without parsed place will always be the smallest lastGroup[GROUP_PLACE_INDEX] = BigInt(0); @@ -322,7 +322,9 @@ const parseFinal = (acc: ParserState): ParserState => { if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((o) => o === acc.lastToken); if (tens > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}`; + lastGroup[GROUP_DIGITS_INDEX] = ( + `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` + ) as GroupDigits; } lastGroup[GROUP_PLACE_INDEX] = BigInt(0); return { @@ -360,11 +362,11 @@ const parseTenPlusOnes = (acc: ParserState, token: string): ParserState => { ...acc, lastToken: token, mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, - groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], + groups: [...acc.groups, [`01${tenPlusOnes}` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}` as GroupDigits; return { ...acc, lastToken: token, @@ -381,13 +383,14 @@ const parseTens = (acc: ParserState, token: string): ParserState => { ...acc, lastToken: token, mode: ParseGroupsMode.TENS_MODE, - groups: [...acc.groups, [`0${tens}0`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], + groups: [...acc.groups, [`0${tens}0` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` - ); + ) as GroupDigits; + return { ...acc, lastToken: token, diff --git a/packages/core/src/systems/en-UK/long-count/stringify.ts b/packages/core/src/systems/en-UK/long-count/stringify.ts index 96928af..a6432f0 100644 --- a/packages/core/src/systems/en-UK/long-count/stringify.ts +++ b/packages/core/src/systems/en-UK/long-count/stringify.ts @@ -1,7 +1,7 @@ import { Group, GROUP_DIGITS_INDEX, - GROUP_PLACE_INDEX, + GROUP_PLACE_INDEX, GroupDigits, GroupPlace, } from '../../../common'; import { numberToExponential } from '../../../exponent'; @@ -179,16 +179,16 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { const currentPlace = BigInt(Math.floor((cc.length - i - 1) / 3)); const newGroup = [EMPTY_GROUP_DIGITS, currentPlace] as Group; if (typeof firstGroup === 'undefined') { - newGroup[GROUP_DIGITS_INDEX] = c; + newGroup[GROUP_DIGITS_INDEX] = c as GroupDigits; return [newGroup]; } if (firstGroup[0].length > 2) { - newGroup[GROUP_DIGITS_INDEX] = c; + newGroup[GROUP_DIGITS_INDEX] = c as GroupDigits; return [newGroup, ...acc]; } - newGroup[GROUP_DIGITS_INDEX] = c + firstGroup[0]; + newGroup[GROUP_DIGITS_INDEX] = (c + firstGroup[0]) as GroupDigits; return [ newGroup, ...acc.slice(1), @@ -309,11 +309,11 @@ export const splitIntoGroups = (value: string): Group[] => { const lastGroupDigits = lastGroup[0].split(''); lastGroupDigits[currentPlaceInGroup] = c; return [...acc.slice(0, -1) ?? [], [ - lastGroupDigits.join(''), + lastGroupDigits.join('') as GroupDigits, currentPlace, ]]; } - return [...acc, [c.padEnd(3, '0'), currentPlace]]; + return [...acc, [c.padEnd(3, '0') as GroupDigits, currentPlace]]; }, [], ); diff --git a/packages/core/src/systems/en-US/short-count/parse.ts b/packages/core/src/systems/en-US/short-count/parse.ts index aaf56bf..e76c04f 100644 --- a/packages/core/src/systems/en-US/short-count/parse.ts +++ b/packages/core/src/systems/en-US/short-count/parse.ts @@ -2,7 +2,7 @@ import { bigIntMax, bigIntMin, Group, GROUP_DIGITS_INDEX, - GROUP_PLACE_INDEX, + GROUP_PLACE_INDEX, GroupDigits, InvalidTokenError, } from '../../../common'; import { @@ -267,14 +267,14 @@ const parseThousand = (acc: ParserState, token: string): ParserState => { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } } else if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((t) => t === acc.lastToken); if (tens > -1) { lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` - ); + ) as GroupDigits; } } @@ -292,7 +292,7 @@ const parseThousand = (acc: ParserState, token: string): ParserState => { const parseHundred = (acc: ParserState): ParserState => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; const hundreds = ONES.findIndex((o) => o === acc.lastToken); - lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}`; + lastGroup[GROUP_DIGITS_INDEX] = `${hundreds}${lastGroup[GROUP_DIGITS_INDEX].slice(1)}` as GroupDigits; return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], @@ -306,7 +306,7 @@ const parseFinal = (acc: ParserState): ParserState => { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 2)}${ones}` as GroupDigits; } // We assume last token without parsed place will always be the smallest lastGroup[GROUP_PLACE_INDEX] = BigInt(0); @@ -320,7 +320,9 @@ const parseFinal = (acc: ParserState): ParserState => { if (acc.mode === ParseGroupsMode.TENS_MODE) { const tens = TENS.findIndex((o) => o === acc.lastToken); if (tens > -1) { - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}`; + lastGroup[GROUP_DIGITS_INDEX] = ( + `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` + ) as GroupDigits; } lastGroup[GROUP_PLACE_INDEX] = BigInt(0); return { @@ -358,11 +360,11 @@ const parseTenPlusOnes = (acc: ParserState, token: string): ParserState => { ...acc, lastToken: token, mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, - groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], + groups: [...acc.groups, [`01${tenPlusOnes}` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } - lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}`; + lastGroup[GROUP_DIGITS_INDEX] = `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}1${tenPlusOnes}` as GroupDigits; return { ...acc, lastToken: token, @@ -379,13 +381,14 @@ const parseTens = (acc: ParserState, token: string): ParserState => { ...acc, lastToken: token, mode: ParseGroupsMode.TENS_MODE, - groups: [...acc.groups, [`0${tens}0`, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], + groups: [...acc.groups, [`0${tens}0` as GroupDigits, lastGroup[GROUP_PLACE_INDEX] - BigInt(1)]], }; } lastGroup[GROUP_DIGITS_INDEX] = ( `${lastGroup[GROUP_DIGITS_INDEX].slice(0, 1)}${tens}${lastGroup[GROUP_DIGITS_INDEX].slice(2)}` - ); + ) as GroupDigits; + return { ...acc, lastToken: token, diff --git a/packages/core/src/systems/en-US/short-count/stringify.ts b/packages/core/src/systems/en-US/short-count/stringify.ts index 86c8390..3b0d8c3 100644 --- a/packages/core/src/systems/en-US/short-count/stringify.ts +++ b/packages/core/src/systems/en-US/short-count/stringify.ts @@ -1,7 +1,7 @@ import { Group, GROUP_DIGITS_INDEX, - GROUP_PLACE_INDEX, + GROUP_PLACE_INDEX, GroupDigits, GroupPlace, } from '../../../common'; import { numberToExponential } from '../../../exponent'; @@ -177,16 +177,16 @@ const getGroupName = (place: GroupPlace, shortenMillia: boolean) => { const currentPlace = BigInt(Math.floor((cc.length - i - 1) / 3)); const newGroup = [EMPTY_GROUP_DIGITS, currentPlace] as Group; if (typeof firstGroup === 'undefined') { - newGroup[GROUP_DIGITS_INDEX] = c; + newGroup[GROUP_DIGITS_INDEX] = c as GroupDigits; return [newGroup]; } if (firstGroup[0].length > 2) { - newGroup[GROUP_DIGITS_INDEX] = c; + newGroup[GROUP_DIGITS_INDEX] = c as GroupDigits; return [newGroup, ...acc]; } - newGroup[GROUP_DIGITS_INDEX] = c + firstGroup[0]; + newGroup[GROUP_DIGITS_INDEX] = (c + firstGroup[0]) as GroupDigits; return [ newGroup, ...acc.slice(1), @@ -307,11 +307,11 @@ export const splitIntoGroups = (value: string): Group[] => { const lastGroupDigits = lastGroup[0].split(''); lastGroupDigits[currentPlaceInGroup] = c; return [...acc.slice(0, -1), [ - lastGroupDigits.join(''), + lastGroupDigits.join('') as GroupDigits, currentPlace, ]]; } - return [...acc, [c.padEnd(3, '0'), currentPlace]]; + return [...acc, [c.padEnd(3, '0') as GroupDigits, currentPlace]]; }, [], );