npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

locale-number-input

v0.3.0

Published

Framework-neutral locale-aware numeric input states and decimal-string normalization.

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.89

A 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.NumberFormat and formatToParts().
  • 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 Intl implementation.

The package supports a tested representative locale matrix. It does not claim universal BCP 47 or numbering-system support.

Installation

npm install locale-number-input

The 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 by normalizeLocaleNumber().
  • 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 returns UNSUPPORTED_LOCALE.
  • mode: defaults to editing. Submit mode rejects missing locale by returning LOCALE_REQUIRED.
  • strictGrouping: defaults to true. Grouped integers must match the locale grouping pattern. false relaxes group widths but still rejects leading, trailing, repeated, fractional, and otherwise misplaced group separators.
  • preserveScale: defaults to true. 1234,50 normalizes to 1234.50; false removes trailing fractional zeroes and emits 1234 for 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 to false. Non-Latin locales require their exact Intl digit 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
OVERFLOW

DiagnosticCode 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,567 is valid in hi-IN, while Western 1,234,567 is not treated as an Indian grouping pattern.
  • A decimal separator must follow at least one integer digit.
  • A decimal separator without fraction digits is partial in 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-run

Tests 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.