locale-number-input
v0.3.0
Published
Framework-neutral locale-aware numeric input states and decimal-string normalization.
Maintainers
Readme
locale-number-input
locale-number-input is a small, framework-neutral TypeScript ESM core for parsing decimal input while a user is editing it. It separates input state (empty, partial, valid, invalid, or ambiguous) from the normalized decimal string an application can send to a backend.
The package is intentionally narrow. It parses decimal input only; it does not format controls, move carets, parse currency symbols, perform financial arithmetic, or infer a locale from punctuation.
Why this package exists
JavaScript's Number() and parseFloat() do not parse locale punctuation or non-Latin digits safely. For example, the same conceptual value can be written as:
id-ID 1.234.567,89
en-US 1,234,567.89
fr-FR 1 234 567,89 (U+202F narrow no-break space)
ar-EG ١٬٢٣٤٬٥٦٧٫٨٩
hi-IN 12,34,567.89A text field also has meaningful intermediate states. -, 1. in en-US, and 1, in id-ID are incomplete editing prefixes, not necessarily malformed user input. The parser reports those states without allowing them through normalization.
Requirements and runtime
- Node.js 18 or newer, or a modern browser with
Intl.NumberFormatandformatToParts(). - ESM output with strict TypeScript declarations.
- Zero runtime dependencies.
- No network access, dynamic evaluation, DOM APIs, or UI framework dependency.
- The result is deterministic for the same input, options, and host
Intlimplementation.
The package supports a tested representative locale matrix. It does not claim universal BCP 47 or numbering-system support.
Installation
npm install locale-number-inputThe published package contains ESM JavaScript, .d.ts declarations, this README, the changelog, and the license. The source package uses npm run build to emit dist/. See CHANGELOG.md for release history.
API
ParseStatus
export type ParseStatus =
| 'empty'
| 'partial'
| 'valid'
| 'invalid'
| 'ambiguous';empty: the raw input is exactly''.partial: a supported editing prefix such as'-'or a decimal separator after integer digits without a fraction. A partial result is never accepted bynormalizeLocaleNumber().valid: the complete input matches the explicit locale grammar and configured limits.invalid: the input contains unsupported characters, malformed structure, invalid grouping, or exceeds a limit.ambiguous: a punctuation-sensitive value cannot be interpreted safely without locale information.
ParseMode
export type ParseMode = 'editing' | 'submit';The default is editing. submit requires an explicit locale and still reports partial input as non-valid; callers must only submit a result whose status is valid.
ParseLocaleNumberOptions
export interface ParseLocaleNumberOptions {
readonly locale?: string;
readonly mode?: 'editing' | 'submit';
readonly strictGrouping?: boolean;
readonly preserveScale?: boolean;
readonly maxInputLength?: number;
readonly maxDigits?: number;
readonly acceptAsciiDigits?: boolean;
}locale: an explicit supported BCP 47 locale. It is required for submit-mode parsing and for any punctuation-sensitive interpretation. An unsupported or structurally invalid locale returnsUNSUPPORTED_LOCALE.mode: defaults toediting. Submit mode rejects missing locale by returningLOCALE_REQUIRED.strictGrouping: defaults totrue. Grouped integers must match the locale grouping pattern.falserelaxes group widths but still rejects leading, trailing, repeated, fractional, and otherwise misplaced group separators.preserveScale: defaults totrue.1234,50normalizes to1234.50;falseremoves trailing fractional zeroes and emits1234for an all-zero fraction.maxInputLength: defaults to 256 Unicode code points. It must be a positive safe integer.maxDigits: defaults to 128 integer-plus-fraction digits. It must be a positive safe integer.acceptAsciiDigits: defaults tofalse. Non-Latin locales require their exactIntldigit glyphs by default. Set this only when an application intentionally accepts ASCII digits as an additional input alphabet; locale separators remain exact.
Input is normalized with Unicode NFC for parsing, but it is never trimmed. Ordinary spaces, tabs, newlines, controls, and unapproved format characters are rejected.
LocaleSymbols
export interface LocaleSymbols {
readonly locale: string;
readonly numberingSystem: string;
readonly decimal: string;
readonly group: string | undefined;
readonly minusSign: string;
readonly negativePrefix: string;
readonly digits: readonly string[];
readonly grouping: readonly number[];
readonly directionalityMarks: readonly string[];
}getLocaleSymbols() derives these values from Intl.NumberFormat().formatToParts() and generated digit sentinels. digits is indexed by ASCII value (digits[0] through digits[9]). grouping describes widths from the right: Western grouping is [3]; Indian grouping is [3, 2]. negativePrefix and directionalityMarks expose the exact locale-derived negative prefix policy.
const symbols = getLocaleSymbols('fr-FR');
// symbols.decimal === ','
// symbols.group === '\u202F'
// symbols.digits === ['0', '1', ... '9']
const indian = getLocaleSymbols('hi-IN');
// indian.grouping === [3, 2]getLocaleSymbols() throws a RangeError named UNSUPPORTED_LOCALE or UNSUPPORTED_NUMBERING_SYSTEM when the requested locale cannot be used by this package. Use parseLocaleNumber() when a result object is preferred.
LocaleNumberResult
export interface LocaleNumberResult {
readonly status: ParseStatus;
readonly input: string;
readonly normalized?: string;
readonly value?: number;
readonly errorCode?: ParseErrorCode;
readonly diagnostics: readonly DiagnosticCode[];
}normalized and value are present only for a complete valid result. normalized is authoritative for transport. value is only a convenience JavaScript number and may not retain decimal precision for large values.
ParseErrorCode values are:
EMPTY
PARTIAL
LOCALE_REQUIRED
INVALID_CHARACTER
INVALID_SIGN
INVALID_GROUPING
AMBIGUOUS_SEPARATOR
UNSUPPORTED_LOCALE
UNSUPPORTED_NUMBERING_SYSTEM
INPUT_TOO_LONG
OVERFLOWDiagnosticCode values are stable machine-readable reasons including EMPTY_INPUT, SIGN_WITHOUT_DIGITS, DECIMAL_SEPARATOR_WITHOUT_FRACTION, INVALID_DIGIT, INVALID_GROUPING, MULTIPLE_DECIMAL_SEPARATORS, GROUP_SEPARATOR_IN_FRACTION, SCIENTIFIC_NOTATION_UNSUPPORTED, WHITESPACE_NOT_ALLOWED, DIRECTIONAL_MARK_NOT_ALLOWED, TOO_MANY_DIGITS, and locale/ambiguity diagnostics. Diagnostics are returned in deterministic order for the first detected failure.
parseLocaleNumber(input, options)
import { parseLocaleNumber } from 'locale-number-input';
const result = parseLocaleNumber('1.234,50', {
locale: 'id-ID',
mode: 'submit',
});
// {
// status: 'valid',
// input: '1.234,50',
// normalized: '1234.50',
// value: 1234.5,
// diagnostics: []
// }The complete input is validated. The parser never accepts a numeric prefix and silently ignores trailing characters.
Required editing examples:
parseLocaleNumber('', { locale: 'en-US' }).status;
// 'empty'
parseLocaleNumber('-', { locale: 'en-US' }).status;
// 'partial'
parseLocaleNumber('1.', { locale: 'en-US' }).status;
// 'partial'
parseLocaleNumber('1,', { locale: 'id-ID' }).status;
// 'partial'
parseLocaleNumber('1.234', { mode: 'editing' }).status;
// 'ambiguous' — no locale was supplied
parseLocaleNumber('abc', { locale: 'en-US' }).status;
// 'invalid'A punctuation-sensitive input without a locale is never guessed. Plain ASCII integers such as 123 can be used in editing mode because they do not require a separator interpretation. Submit mode requires a locale even for a plain integer:
parseLocaleNumber('1.234', { mode: 'editing' }).errorCode;
// 'LOCALE_REQUIRED'
parseLocaleNumber('123', { mode: 'submit' }).errorCode;
// 'LOCALE_REQUIRED'Explicit locale semantics always win. For example, 1.234 is a valid grouped 1234 in id-ID, while it is a valid decimal 1.234 in en-US. Punctuation from another locale is not silently converted.
normalizeLocaleNumber(input, options)
import { normalizeLocaleNumber } from 'locale-number-input';
const normalized = normalizeLocaleNumber('1.234,50', {
locale: 'id-ID',
mode: 'submit',
});
// '1234.50'This function uses the same parser and forces submit semantics. It returns only a complete valid normalized string and throws LocaleNumberParseError for empty, partial, invalid, ambiguous, unsupported locale, unsupported numbering system, limit, and overflow results.
import {
LocaleNumberParseError,
normalizeLocaleNumber,
} from 'locale-number-input';
try {
normalizeLocaleNumber('1.', { locale: 'en-US', mode: 'submit' });
} catch (error) {
if (error instanceof LocaleNumberParseError) {
console.log(error.result.status); // 'partial'
console.log(error.result.errorCode); // 'PARTIAL'
}
}Supported release-gate matrix
The following 20 locale/numbering-system pairs are tested and supported:
| Locale | Numbering system | Group | Decimal | Grouping |
| --- | --- | --- | --- | --- |
| id-ID | latn | . | , | Western |
| en-US | latn | , | . | Western |
| de-DE | latn | . | , | Western |
| fr-FR | latn | U+202F narrow no-break space | , | Western |
| ar-EG | arab | U+066C ٬ | U+066B ٫ | Western |
| hi-IN | latn | , | . | Indian |
| pt-BR | latn | . | , | Western |
| es-ES | latn | . | , | Western |
| it-IT | latn | . | , | Western |
| nl-NL | latn | . | , | Western |
| ru-RU | latn | U+00A0 no-break space | , | Western |
| pl-PL | latn | U+00A0 no-break space | , | Western |
| sv-SE | latn | U+00A0 no-break space | , | Western |
| cs-CZ | latn | U+00A0 no-break space | , | Western |
| uk-UA | latn | U+00A0 no-break space | , | Western |
| ja-JP | latn | , | . | Western |
| zh-CN | latn | , | . | Western |
| fa-IR | arabext | U+066C ٬ | U+066B ٫ | Western |
| bn-BD | beng | , | . | Indian |
| ar-SA | arab | U+066C ٬ | U+066B ٫ | Western |
Use the exact characters emitted by Intl. For example, replacing French U+202F or Russian U+00A0 with an ordinary ASCII space is invalid. Arabic and Persian directionality marks are accepted only when they are the locale-derived negative-prefix mark in the expected position; arbitrary bidi marks are rejected.
A locale with a different or unsupported default numbering system returns UNSUPPORTED_LOCALE or UNSUPPORTED_NUMBERING_SYSTEM rather than silently falling back. For example, en-US-u-nu-arab is outside the tested en-US/latn pair. Compatibility locales are not part of the public support claim.
Grouping and sign policy
With the default strictGrouping: true:
- An ungrouped integer is valid.
- A grouped integer must match its locale pattern.
- Group separators cannot be leading, trailing, repeated, next to the decimal separator, or present in the fraction.
- Indian grouping is validated independently:
12,34,567is valid inhi-IN, while Western1,234,567is not treated as an Indian grouping pattern. - A decimal separator must follow at least one integer digit.
- A decimal separator without fraction digits is
partialin editing and submit parsing, but it cannot be normalized.
The locale minus sign and ASCII - are accepted at the beginning, including the exact locale-derived bidi prefix when one is emitted by Intl. A plus sign is always rejected. A sign anywhere else is invalid.
Scientific notation (1e3), Infinity, NaN, currency/percent/unit symbols, arbitrary punctuation, prefixes, suffixes, and trailing junk are outside the decimal-input grammar and are rejected.
Normalization and backend transport
Normalized output always uses ASCII digits and . as the decimal separator. It preserves a leading minus sign, removes grouping symbols, canonicalizes redundant leading integer zeroes, and preserves fractional scale by default:
normalizeLocaleNumber('0001.20', {
locale: 'en-US',
mode: 'submit',
});
// '1.20'
normalizeLocaleNumber('1.234,00', {
locale: 'de-DE',
mode: 'submit',
preserveScale: false,
});
// '1234'For a backend payload, keep the raw value and locale alongside the authoritative normalized string:
{
"raw": "1.234,50",
"locale": "id-ID",
"normalized": "1234.50"
}The package does not perform money arithmetic, currency conversion, exchange-rate calculation, financial rounding, or precision guarantees for the convenience value number. Use a decimal or money library on the backend or in a separately specified calculation layer.
Limits and error handling
The default limits are deliberately bounded:
- 256 Unicode code points per input.
- 128 total integer and fraction digits.
An input over either limit returns INPUT_TOO_LONG and a deterministic limit diagnostic. A finite normalized string that cannot be represented by the convenience JavaScript number returns OVERFLOW; the parser does not return a misleading Infinity value. Limit options must be positive safe integers.
No parser function performs network I/O or uses eval/Function. getLocaleSymbols() and parsing use only built-in Intl data and local string processing.
Non-goals
locale-number-input focuses exclusively on parsing and normalizing locale-aware decimal input.
It does not provide:
- UI components, input masking, caret management, or automatic formatting.
- Currency, percentage, unit, or scientific-notation parsing.
- Financial arithmetic, rounding, or arbitrary-precision calculations.
- Automatic locale detection or separator guessing.
- Form validation, networking, or backend integrations.
- Universal locale or numbering-system support.
Development
npm ci --ignore-scripts
npm run typecheck
npm test
npm run build
npm pack --dry-runTests use Node's built-in test runner and do not require network access or runtime dependencies. The release-gate suite covers grouped, ungrouped, negative, malformed-grouping, Unicode digit, whitespace, directionality, partial-input, limit, overflow, and parse/normalize/parse cases for the representative matrix above.
