Localized decimal input fails when the keyboard, parser, validator, and storage layer disagree about a comma or period. A user enters 12,50, the UI removes the comma, and the app submits 1250. Another user pastes 1,234, which could mean one point two three four or one thousand two hundred thirty-four. Correct display formatting cannot repair a number that was misread during entry. Keep editable text separate from numeric data, parse against an explicit input locale when the user commits the field, and reject ambiguous or partly parsed text. Only a locale-neutral number should cross the API boundary.
Why localized number formatting is not enough
Displaying a number and editing one are different jobs. A formatter starts with a valid numeric value and produces text. An input field starts with incomplete, possibly ambiguous text and must decide whether it can become a valid value.
A decimal field usually crosses four representations:
- Editable text, including temporary states such as an empty field,
-,1,, or0.. - A locale-aware parsed number used by validation and calculations.
- A canonical in-memory value, preferably decimal rather than binary floating point for money.
- A transport and storage representation whose syntax does not depend on a user's locale.
Collapsing those representations into one string creates the failure. If the app reformats after every keystroke, 1, may become 1 before the user can type the fraction. If it replaces every comma with a period, a pasted grouping separator may become a decimal separator. If the backend parses according to its process locale, the same payload may produce a different value after deployment.
Unicode CLDR defines locale-specific number symbols and numbering systems. Those rules affect decimal signs, grouping signs, digits, plus and minus signs, and formatting patterns. They are evidence that punctuation is not a universal numeric grammar. They are not a reason to accept every possible symbol in one field.
Define the input locale before parsing
Choose one effective input locale for the field. It should normally match the language and number conventions presented by the app, not an arbitrary server locale. Record how it was chosen, especially if the app allows its language to differ from the device language.
Put those choices in the field configuration:
DecimalInputContract
localeTag: "de-DE"
decimalSeparator: ","
groupingPolicy: REJECT_WHILE_EDITING
acceptedDigitSet: LOCALE_AND_ASCII
maximumFractionDigits: 2
allowNegative: false
compatibilitySeparator: "."
The compatibility separator is optional. A German user may receive a period from a hardware keyboard even when the visible software keyboard offers a comma. Supporting that case can be reasonable, but only when the text contains no grouping sign and the alternative has one unambiguous interpretation. Do not silently accept both comma and period when both appear. Ask the user to correct the value instead.
For ordinary editing, reject grouping unless the product has a specific reason to accept it. A grouping separator makes pasted text ambiguous, and in many locales that separator is a space that resembles ordinary whitespace. The app can add grouping for display after the field loses focus, then restore an ungrouped edit representation when focus returns.
Preserve partial edit states
Do not require every keystroke to parse as a complete number. The field needs a small state machine that distinguishes editable, committed, and invalid states.
EMPTY
PARTIAL_SIGN
INTEGER
PARTIAL_DECIMAL
FRACTION
INVALID
For a locale whose decimal separator is a comma, 1, is PARTIAL_DECIMAL, not an error and not the committed integer 1. The user must be able to add the next digit. A second decimal separator, a forbidden minus sign, too many fraction digits, or an unexpected letter can move the text to INVALID without replacing what the user typed.
Separate raw text from the committed value so validation does not fight the user. Wait until blur or submit to report an incomplete value. Impossible characters and fixed precision limits can still produce immediate feedback, but validation must not coerce the text into a different number.
Keep these values in state:
DecimalFieldState
rawText: "12,"
editState: PARTIAL_DECIMAL
committedValue: 10.00
inputLocaleTag: "de-DE"
errorCode: null
The last committed value remains available to the domain layer while the user edits. The raw text remains under user control. Only a successful commit replaces committedValue.
Parse once at the commit boundary
Use the platform formatter configured with the field's input locale. Apple's NumberFormatter.number(from:) creates a number using the formatter's configured format. Apple states that parsing fails when the string contains characters other than digits or locale-appropriate grouping and decimal separators. Android's NumberFormat.parse provides the corresponding locale-aware parse boundary.
At blur or submit, run these checks in order:
- Normalize ordinary surrounding whitespace without changing internal symbols.
- Reject empty and partial states.
- Resolve any explicitly allowed compatibility separator.
- Parse with a formatter bound to
inputLocaleTag. - Confirm that parsing consumed the complete normalized text.
- Apply domain constraints such as sign, range, and fraction precision.
- Convert to the canonical decimal type.
- Update the committed value only after every check passes.
function commitDecimal(rawText, contract): Result<Decimal> {
text = trimOuterWhitespace(rawText)
state = classifyEditState(text, contract)
if state is EMPTY or PARTIAL_SIGN or PARTIAL_DECIMAL:
return error(INCOMPLETE_NUMBER)
normalized = resolveCompatibilitySeparator(text, contract)
if normalized is AMBIGUOUS:
return error(AMBIGUOUS_SEPARATORS)
result = parseLocalizedNumberCompletely(
normalized,
locale = contract.localeTag
)
if result failed:
return error(INVALID_NUMBER)
decimal = convertExactly(result.number)
if fractionDigits(decimal) > contract.maximumFractionDigits:
return error(TOO_MANY_FRACTION_DIGITS)
if decimal < 0 and not contract.allowNegative:
return error(NEGATIVE_NOT_ALLOWED)
return success(decimal)
}
Some parsing APIs expose a parse position or return a number from a valid prefix. Check that position before accepting the result. The text 12,5kg must not become 12.5 simply because the parser understood the first characters. Wrap the platform parser so the application receives either one fully consumed number or a typed error.
Use platform symbols instead of hardcoded punctuation
Do not infer the decimal sign from a language code with a custom map. Android exposes the active locale's sign through DecimalFormatSymbols.getDecimalSeparator. The formatter and the input classifier should use the same resolved locale and symbol data.
On iOS, configure one NumberFormatter for the input contract and use that formatter for parsing. Do not use the displayed app language for one check and the device region for another. The exact locale object should travel with the field configuration.
The software keyboard is a hint, not a validation contract. A Flutter developer reported a German iOS decimal field showing a period key until German was declared in the iOS bundle. That report documents one implementation failure, not a guarantee that every keyboard follows app localization. Hardware keyboards, paste, autofill, accessibility input, and third-party keyboards can still supply other text. Validate the actual string.
Keyboard configuration should improve the likely input path while the parser remains authoritative. Declare supported app localizations correctly, request a decimal-capable keyboard, and keep a visible way to enter the expected separator. Never remove a character and continue as if the value were unchanged.
Keep storage and APIs locale neutral
Once input has been parsed, stop carrying localized punctuation into business logic. Store a decimal value or a scaled integer with an explicit scale. For money, that could be minor units when the currency has a stable minor-unit rule, or a decimal type when the domain requires variable precision.
For JSON, agree on one locale-neutral representation. A JSON number uses a period in its grammar, but many financial systems choose a string plus an explicit scale to avoid binary floating-point conversion. Either approach can work if the schema is documented and validated. Sending "12,50" and asking the server to guess the user's locale is not a contract.
Include the locale only when the server needs it for a separate purpose, such as producing localized validation copy. It must not be required to recover the numeric magnitude. The canonical value should round trip through clients with different app languages without changing.
Avoid logging only the raw number when diagnosing failures. Safe telemetry can record the field identifier, input locale tag, error code, keyboard type, and whether text came from paste, without recording sensitive user-entered values. This makes separator mismatches visible without putting prices, health measurements, or financial details into logs.
Handle locale changes without rewriting active input
An app language may change while a decimal field is focused. Reinterpreting existing text under the new locale can silently change its meaning. Do not turn 1,234 from a decimal into a grouped integer because the locale changed.
Use a clear rule:
- If the field is clean, reformat the committed value for the new locale.
- If the field has uncommitted edits, keep its input locale snapshot until commit or cancel.
- After commit, format the canonical value with the new display locale.
- If the product requires immediate migration, show the proposed converted text and require confirmation.
The input locale now belongs to the edit session. The rest of the screen may adopt the new display locale without changing the meaning of text already being edited.
Test the whole input lifecycle
Unit tests should cover symbols, partial states, complete parsing, and canonical conversion. UI tests should exercise the actual keyboard and locale declarations on supported operating systems.
Begin with concrete fixtures:
12.50underen-UScommits as decimal 12.50.12,50underde-DEcommits as decimal 12.50.12,remains partial while focused and fails as incomplete on submit.1,234under each locale follows the documented grouping policy.1,234.56and1.234,56are either accepted under the matching locale or rejected consistently when grouping is disabled.- A value with trailing letters fails complete-consumption validation.
- Arabic-script digits follow the declared accepted digit policy.
- Paste, hardware keyboard, software keyboard, and accessibility input produce the same validation result.
- Switching app language during a dirty edit does not reinterpret the raw text.
- A committed value survives an API round trip through clients using different locales.
Add property tests around boundary values and fraction precision. Generate valid canonical decimals, format them for each supported input locale, parse them back, and assert equality. Then mutate separators and grouping to confirm that ambiguous strings fail rather than changing magnitude.
Common mistakes to remove
Replacing commas with periods before parsing is the most dangerous shortcut. It ignores grouping, alternate digits, and strings containing both symbols. Parsing on every keystroke is another common mistake because it cannot preserve incomplete text. Trusting the decimal keyboard fails for paste and external keyboards. Letting the server infer locale from a user profile can also disagree with an app-specific language or an edit session that began before a preference change.
A larger regular expression still mixes interaction rules, locale parsing, and storage. Use a small edit-state classifier while the user types. Let platform locale data supply the symbols, use a locale-bound formatter for complete parsing, and move the committed result into a canonical decimal schema. Each layer then has one testable job.
Make the next field safe
Choose one production decimal field, preferably a price or quantity where a changed magnitude causes real damage. Trace the raw text, input locale, parser, canonical type, API payload, and display formatter. Before changing the implementation, add tests for comma and period locales, partial input, ambiguous paste, and a cross-locale round trip. Replace coercive separator cleanup with parsing at the commit boundary. Once that field works end to end, extract the behavior into the shared numeric-input component.
References
- Apple
NumberFormatter.number(from:)documents parsing with a formatter's configured format and locale-appropriate separators. - Android
NumberFormat.parsedefines the Android locale-aware number parsing boundary. - Android
DecimalFormatSymbols.getDecimalSeparatorexposes the decimal sign selected for a locale. - Unicode CLDR number formats define locale number symbols, patterns, and numbering systems.
- Stack Overflow: German decimal keyboard in a Flutter iOS app provides a practitioner report about app locale declarations and decimal keyboard behavior.
