Skip to content

Commit

Permalink
add locale number parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
andrej-dyck committed Dec 16, 2023
1 parent 827c185 commit b6c4f92
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 2 deletions.
1 change: 1 addition & 0 deletions locale/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './localeNumber.js'
57 changes: 57 additions & 0 deletions locale/localeNumber.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
40 changes: 40 additions & 0 deletions locale/localeNumber.ts
Original file line number Diff line number Diff line change
@@ -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<Locale, LocaleSeparators>()
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
/* Base Options */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ESNext",
"target": "ES2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
Expand All @@ -27,7 +27,7 @@
"strictNullChecks": true,

/* Libs */
"lib": ["ESNext"],
"lib": ["ES2022"],
"types": ["node", "./json/index.d.ts"],

"outDir": "./dist"
Expand Down

0 comments on commit b6c4f92

Please sign in to comment.