From b6c4f927532e4b241dfd335fa10757a941dc65f2 Mon Sep 17 00:00:00 2001 From: Andrej Dyck <43913051+andrej-dyck@users.noreply.github.com> Date: Sat, 16 Dec 2023 16:20:14 +0100 Subject: [PATCH] add locale number parsing --- locale/index.ts | 1 + locale/localeNumber.test.ts | 57 +++++++++++++++++++++++++++++++++++++ locale/localeNumber.ts | 40 ++++++++++++++++++++++++++ tsconfig.json | 4 +-- 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 locale/index.ts create mode 100644 locale/localeNumber.test.ts create mode 100644 locale/localeNumber.ts diff --git a/locale/index.ts b/locale/index.ts new file mode 100644 index 0000000..28a66a4 --- /dev/null +++ b/locale/index.ts @@ -0,0 +1 @@ +export * from './localeNumber.js' diff --git a/locale/localeNumber.test.ts b/locale/localeNumber.test.ts new file mode 100644 index 0000000..88950e9 --- /dev/null +++ b/locale/localeNumber.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, test } from 'vitest' +import { localeNumber, Locale } from './localeNumber.js' + +describe('localeNumber', () => { + + const numbers = [ + 0, 1, 1.1, -1, 0.5, -17.42, 2001, 2777227.19, 9007199254740991, -9007199254740991, + ] + + test.each(numbers)('parses numbers in German format; %j', (number) => { + const locale = 'de-DE' + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + test.each(numbers)('parses numbers in Invariant Country format; %j', (number) => { + const locale = 'us-US' as unknown as Locale + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + test.each(numbers)('parses numbers in French format; %j', (number) => { + const locale = 'fr-FR' as unknown as Locale + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + test.each(numbers)('parses numbers in Switzerland format; %j', (number) => { + const locale = 'de-CH' as unknown as Locale + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + test.each(numbers)('parses numbers in Kyrgyzstan format; %j', (number) => { + const locale = 'ky-KG' as unknown as Locale + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + test.each(numbers)('parses numbers in Estonia format; %j', (number) => { + const locale = 'et-EE' as unknown as Locale + const formattedNumber = Intl.NumberFormat(locale).format(number) + expect(localeNumber(locale)(formattedNumber)).toBe(number) + }) + + it.each([ + '1 000 172,3', '1,000,172.30', + ])('is undefined when number is formatted in a different locale; %j', (value) => { + expect(localeNumber('de-DE')(value)).toBeUndefined() + }) + + it.each([ + '', 'NaN', 'Infinity', 'null', 'abc', + ])('is undefined for non-numbers; %j', (value) => { + expect(localeNumber('de-DE')(value)).toBeUndefined() + }) +}) diff --git a/locale/localeNumber.ts b/locale/localeNumber.ts new file mode 100644 index 0000000..dc3f149 --- /dev/null +++ b/locale/localeNumber.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +export type Locale = string // todo: ideally 'de-DE' | 'us-US' | 'fr-FR' | ... + +/** + * Parses number formatted as a local string (must still consist of 0-9 digits) + * using Intl.NumberFormat(locale) to determine decimal and group separators + */ +export const localeNumber = (locale: Locale) => (number: string): number | undefined => { + if (number.length === 0) return undefined + + const { decimal, group, minusSign } = cachedSeparators(locale) + const parsed = Number( + number.replace(minusSign, '-').replaceAll(group, '').replaceAll(decimal, '.') + ) + return Number.isFinite(parsed) ? parsed : undefined +} + +/* + * Example: + * const germanNumber = localeNumber('de-DE') + * const n = germanNumber('-2.777.227,19') // -> -2777227.19 + */ + +type LocaleSeparators = { locale: Locale, decimal: string, group: string, minusSign: string } + +const localeSeparators = (locale: Locale): LocaleSeparators => { + const parts = new Intl.NumberFormat(locale).formatToParts(-10000.1) + return { + locale, + decimal: parts.find(p => p.type === 'decimal')!.value, + group: parts.find(p => p.type === 'group')!.value, + minusSign: parts.find(p => p.type === 'minusSign')!.value, + } +} + +const cachedSeparators = (locale: Locale): LocaleSeparators => + separators.get(locale) ?? separators.set(locale, localeSeparators(locale)).get(locale)! + +const separators = new Map() diff --git a/tsconfig.json b/tsconfig.json index 7188578..c9f8ce2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ /* Base Options */ "esModuleInterop": true, "skipLibCheck": true, - "target": "ESNext", + "target": "ES2022", "allowJs": true, "resolveJsonModule": true, "moduleDetection": "force", @@ -27,7 +27,7 @@ "strictNullChecks": true, /* Libs */ - "lib": ["ESNext"], + "lib": ["ES2022"], "types": ["node", "./json/index.d.ts"], "outDir": "./dist"