Add features and limitations to the project. Also update the types to be stricter.master
@@ -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`). |
@@ -1,3 +1,3 @@ | |||
- [ ] Ordinals | |||
- [ ] Fractions | |||
- [ ] Other locales (long count, languages, etc.) | |||
- [ ] ~~Fractions~~ | |||
- [X] Other locales (long count, languages, etc.) |
@@ -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') { | |||
@@ -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}`); | |||
@@ -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, | |||
@@ -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, | |||
@@ -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]]; | |||
}, | |||
[], | |||
); | |||
@@ -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, | |||
@@ -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]]; | |||
}, | |||
[], | |||
); | |||