Formattare i numeri per sei lingue: separatori, valuta e il ritorno al valore
Pubblicato il 03/10/2025 · 13 min di lettura · Strumenti testo e lingua
Daniel Okonkwo — Sviluppatore front-end e redattore Tech presso Allin
Performance web · Formati di file
Verificato su 5 fonti
La stessa quantità si scrive 1.234.567,891 in italiano, spagnolo e tedesco, 1 234 567,891 in francese e 1,234,567.891 in inglese, e gli spazi della versione francese non sono spazi digitabili. Eseguendo Intl.NumberFormat su Node 26 con ICU 78 si ottiene U+202F NARROW NO-BREAK SPACE come separatore di gruppi del francese e U+00A0 NO-BREAK SPACE per il portoghese europeo, entrambi invisibili ed entrambi fatali per un analizzatore che si aspetta uno spazio normale o una virgola. Tre delle sei lingue si rifiutano inoltre di raggruppare i numeri a quattro cifre: spagnolo, portoghese europeo e italiano scrivono 1234 senza separatore ma 10.000 con esso, perché CLDR fissa a due il loro minimo di cifre raggruppate. La disposizione monetaria si divide allo stesso modo: l'inglese mette il simbolo prima delle cifre senza nulla in mezzo, le altre cinque lo mettono dopo il numero con uno spazio unificatore. Nulla di tutto ciò si disfa con Number.parseFloat, che restituisce 1 per la stringa inglese e un plausibile 1,234 per quella tedesca. L'unica analisi sicura chiede a Intl.NumberFormat.formatToParts quali caratteri quella lingua usa davvero, poi toglie il separatore di gruppi prima di convertire il decimale — in quest'ordine, perché al contrario il valore viene moltiplicato per mille.
1.234,56 e 1,234.56 sono lo stesso numero, e confonderli cambia il valore che un lettore legge. Abbiamo eseguito Intl.NumberFormat per tutte e sei le lingue del sito e stampato ogni separatore — compreso quello invisibile usato dal francese — e poi misurato perché parseFloat non può disfare nulla di tutto ciò.
Lo stesso numero, sei grafie
Abbiamo preso un valore, 1234567.891, e lo abbiamo formattato con Intl.NumberFormat nelle sei lingue in cui questo sito pubblica. L'inglese dà 1,234,567.891. Spagnolo, tedesco e italiano danno tutti 1.234.567,891: virgola e punto si sono scambiati i ruoli, così chi applica abitudini inglesi legge un numero mille volte più piccolo senza accorgersene. Il francese dà 1 234 567,891 e il portoghese europeo pure, ma con un carattere invisibile diverso a fare la spaziatura.
Non è un dettaglio di presentazione. Una colonna di foglio di calcolo incollata da una convenzione in un sistema che ne attende un'altra cambia in silenzio ogni suo valore, e il guasto è invisibile perché entrambe le grafie sono numeri legali. La regola da interiorizzare: un numero formattato è un testo su un valore, in una lingua precisa, e la lingua deve viaggiare con esso.
Altre due convenzioni vale la pena conoscerle perché compaiono nei dati europei. Il tedesco di Svizzera usa l'apostrofo: de-CH formatta lo stesso valore come 1'234'567.891, con il punto per il decimale. E un'etichetta di lingua nuda non equivale a una di lingua e regione: chiedere «pt» a Intl produce convenzioni brasiliane, 1.234.567,891, mentre «pt-PT» produce la forma europea con spazi. Se il tuo pubblico lusofono è europeo, l'etichetta nuda è silenziosamente sbagliata.
Il separatore che non si vede
Il separatore di gruppi francese nei dati CLDR attuali non è la barra spaziatrice. Chiedere a Intl.NumberFormat le parti di un numero francese restituisce U+202F NARROW NO-BREAK SPACE. Il portoghese europeo usa U+00A0 NO-BREAK SPACE, un carattere diverso di larghezza diversa. Entrambi sembrano esattamente uno spazio in ogni editor, terminale e browser, e nessuno dei due lo è.
Le conseguenze sono del tutto pratiche. Una regola di validazione scritta /^[\d ,]+$/ rifiuta un numero francese formattato correttamente, perché il carattere che contiene non è lo spazio della classe. Un trova-e-sostituisci che toglie gli spazi lascia il separatore intatto. Un'esportazione CSV produce celle che il foglio di calcolo legge come testo e non come numeri. E copiare il numero da una pagina per incollarlo in una calcolatrice dà un errore che nessuno sa spiegare, perché il carattere colpevole è invisibile da entrambi i lati dell'incollaggio.
La mossa giusta è non indovinare mai il separatore. Intl.NumberFormat(locale).formatToParts(12345.6) restituisce un piccolo array in cui una voce ha tipo «group» e un'altra tipo «decimal», e i loro valori sono esattamente i caratteri che quella lingua usa oggi. Leggili a runtime e il tuo codice continua a funzionare quando CLDR cambia, cosa che accade: il separatore francese nei dati più vecchi era uno spazio unificatore normale, prima che quello stretto lo sostituisse.
Tre lingue non raggruppano i numeri a quattro cifre
Formatta 1234 nelle sei lingue e i risultati non combaciano. L'inglese dà 1,234, il francese 1 234 e il tedesco 1.234, ma spagnolo, portoghese europeo e italiano danno tutti 1234, senza alcun separatore. Sali a 10000 e tutte raggruppano: 10,000, 10 000, 10.000 e 10.000 rispettivamente. Il passaggio avviene fra le quattro e le cinque cifre.
La regola alla base è un'impostazione CLDR chiamata minimo di cifre raggruppate, che dice quante cifre devono stare a sinistra del primo separatore perché il raggruppamento valga la pena. Spagnolo, portoghese europeo e italiano la fissano a due; inglese, francese e tedesco a uno. Esiste perché in quelle lingue un numero a quattro cifre è spesso un anno o un riferimento e si legge meglio non spezzato. Se il raggruppamento serve comunque — una tabella di importi le cui colonne devono allinearsi — passa useGrouping: "always" e lo spagnolo restituisce 1.234. L'opzione inversa, useGrouping: "min2", fa comportare l'inglese come lo spagnolo e stampare 1234.
Valuta e percentuale: dove va il simbolo
Per un importo di 1234,50 l'inglese formatta $1,234.50 — simbolo prima, senza spazio, raggruppando dal migliaio. Le cinque lingue europee mettono tutte il simbolo alla fine, e tutte uno spazio unificatore prima: il francese produce l'importo con spazi stretti unificatori nelle cifre, poi uno spazio unificatore e il simbolo €; il tedesco produce 1.234,50 seguito da quello spazio e dal simbolo; spagnolo, portoghese e italiano producono 1234,50 seguito dallo stesso, non raggruppato per la regola delle quattro cifre di cui sopra.
Quello spazio unificatore prima del simbolo è un carattere reale ed è lì di proposito: impedisce a un'interruzione di riga di separare l'importo dalla sua unità. Rompe anche gli stessi analizzatori ingenui del separatore di gruppi, e fa fallire nei test il confronto di stringhe con un valore atteso scritto a mano senza motivo visibile. Confronta l'output formattato con formatToParts, non per uguaglianza con un letterale che hai digitato.
La percentuale ha la sua trappola, e non riguarda i separatori. style: "percent" moltiplica per 100 prima di formattare. Passare 12,34 perché hai già convertito il rapporto dà 1.234% — un numero cento volte più grande, stampato senza lamentele. Passa il rapporto grezzo, 0,1234, e ottieni 12,34%. Anche la spaziatura differisce: l'inglese scrive 12.3% senza spazio, francese, spagnolo e tedesco inseriscono uno spazio unificatore prima del segno, portoghese e italiano no.
Cifre decimali, arrotondamento e le trappole nelle opzioni
Il valore predefinito per un numero semplice è un massimo di tre cifre decimali, quindi formattare 1,23456 dà 1,235 e il resto sparisce. Per una valuta il valore predefinito viene dalla valuta stessa: due cifre per dollaro ed euro, zero per lo yen, tre per il dinaro tunisino. Di solito è ciò che si vuole, e vale la pena sapere che accade invece di presumere due ovunque.
Le due opzioni che generano ticket di assistenza sono minimumFractionDigits e maximumFractionDigits. Impostare il minimo sopra il massimo solleva un RangeError invece di troncare, il che almeno è rumoroso. Impostare solo il minimo alza in silenzio il massimo per farlo combaciare: minimumFractionDigits: 4 su 1,23456789 stampa 1,2346 e non le due cifre che forse ti aspettavi da altrove nel codice.
L'arrotondamento ha un valore predefinito da conoscere. Intl arrotonda la metà allontanandosi da zero: 2,5 diventa 3 e -0,5 diventa -1 con zero decimali; passa roundingMode: "halfEven" e 2,5 diventa 2, che è ciò che contabilità e statistica di solito vogliono. E Intl non è toFixed: arrotondare 1,005 a due decimali dà 1,01 con Intl e 1,00 con toFixed, e arrotondare 2,675 dà 2,68 con Intl e 2,67 con toFixed. La differenza è che toFixed arrotonda il double binario, il cui valore sta pochissimo sotto il decimale che hai scritto, mentre Intl arrotonda il decimale che intendevi.
La notazione compatta, che è una traduzione e non un'abbreviazione
Impostare notation: "compact" trasforma 1234567 in 1.2M in inglese. Le altre cinque lingue non usano tutte M: il francese dà l'importo con una M, spagnolo e portoghese pure, il tedesco dà Mio. e l'italiano Mln. In forma lunga le differenze sono più nette: 1.2 million, 1,2 million, 1,2 millones, 1,2 milhões, 1,2 Millionen, 1,2 milioni.
Le migliaia sono ancora più disomogenee. L'inglese dà 1.5K per 1500, il francese 1,5 k con k minuscola, spagnolo e portoghese 1,5 mil, l'italiano 1,5K — e il tedesco dà 1500, invariato, perché CLDR non ha una forma compatta breve per le migliaia in tedesco. Se il tuo cruscotto presume che tutte le lingue accorcino allo stesso modo, la colonna tedesca sarà più larga delle altre e non c'è nulla da configurare al riguardo.
Perché parseFloat non può disfare nulla di tutto ciò
toLocaleString è una funzione a senso unico. Abbiamo formattato 1234567,891 in ciascuna delle sei lingue e ridato il risultato così com'è a Number.parseFloat. L'inglese ha restituito 1. Il francese ha restituito 1. Il portoghese ha restituito 1. Spagnolo, tedesco e italiano hanno restituito 1,234. Nessuno ha restituito il valore originale, e gli ultimi tre sono i pericolosi, perché 1,234 è un numero perfettamente plausibile che nessuna validazione rifiuterà.
Number() è almeno onesto: restituisce NaN per tutte e sei, perché nessuna è un letterale numerico valido. Questo rende Number() la guardia migliore se stai solo verificando che una stringa sia un numero macchina nudo, e lo rende inutile come analizzatore di qualsiasi cosa letta da una persona.
L'ordine della rimozione conta più di quanto si creda. Prendi la stringa tedesca 1.234.567,891. Togli prima i punti, poi trasforma la virgola in punto: ottieni 1234567.891, corretto. Trasforma prima la virgola in punto e poi togli i punti: ottieni 1234567891, mille volte più grande e comunque un intero plausibile. Sono entrambe funzioni di due righe e solo una è giusta.
Abbiamo scritto la versione guidata dalla lingua e l'abbiamo provata su tutte e sei: leggere i caratteri di gruppo e decimale da formatToParts, cancellare ogni occorrenza del carattere di gruppo, sostituire il carattere decimale con un punto, buttare tutto ciò che resta e non è cifra né segno, poi convertire. Ha riprodotto esattamente il valore originale in tutte e sei, e ha analizzato anche le sei stringhe di valuta, simboli e spazi unificatori compresi, senza alcun codice specifico per lingua.
| Lingua | 1234567,891 | Separatore di gruppi | Separatore decimale | Importo, 1234,50 |
|---|---|---|---|---|
| en-US | 1,234,567.891 | Virgola | Punto | $1,234.50 (simbolo prima) |
| fr-FR | 1 234 567,891 | U+202F spazio stretto unificatore | Virgola | 1 234,50 € (simbolo in fondo) |
| es-ES | 1.234.567,891 | Punto | Virgola | 1234,50 € (nessun raggruppamento sotto 10.000) |
| pt-PT | 1 234 567,891 | U+00A0 spazio unificatore | Virgola | 1234,50 € (nessun raggruppamento sotto 10.000) |
| de-DE | 1.234.567,891 | Punto | Virgola | 1.234,50 € (simbolo in fondo) |
| it-IT | 1.234.567,891 | Punto | Virgola | 1234,50 € (nessun raggruppamento sotto 10.000) |
Domande frequenti
- Quale separatore usa davvero il francese per le migliaia?
- U+202F NARROW NO-BREAK SPACE, nei dati CLDR correnti al momento in cui scriviamo: lo abbiamo letto direttamente da formatToParts su Node 26 con ICU 78. Non è la barra spaziatrice né U+00A0, che è quello usato dal portoghese europeo. Versioni più vecchie di CLDR usavano U+00A0 anche per il francese, quindi qualsiasi codice che ne fissi uno si romperà a un aggiornamento del runtime. Leggi il separatore a runtime e la domanda smette di contare.
- Perché 1234 si stampa senza separatore in spagnolo e italiano?
- Perché CLDR fissa a due il minimo di cifre raggruppate di quelle lingue, il che significa che il raggruppamento inizia solo quando ci sono almeno due cifre prima del primo separatore. Così 1234 si scrive nudo e 10.000 è raggruppato. È deliberato: in quelle lingue i numeri a quattro cifre sono spesso anni o riferimenti. Passa useGrouping: "always" se il separatore ti serve comunque, per esempio per tenere allineata una colonna di cifre.
- Posso usare toLocaleString e parseFloat in coppia?
- No, e il fallimento è silenzioso. Abbiamo formattato 1234567,891 in sei lingue ed eseguito parseFloat su ogni risultato: tre hanno restituito 1 e tre hanno restituito 1,234. Nessuno ha restituito l'originale. Number() restituisce almeno NaN per tutte e sei, quindi fallisce rumorosamente. Formattare serve a mostrare, e analizzare richiede un proprio percorso di codice guidato dai separatori della lingua.
- Devo salvare numeri formattati o grezzi?
- Grezzi, sempre, e formatta all'ultimo momento possibile. Un valore salvato 1234567.891 è privo di ambiguità e l'aritmetica ci funziona sopra. Una stringa salvata 1.234.567,891 porta con sé una lingua che poi bisogna ricordare, non ordina numericamente e trasforma ogni calcolo in un'analisi. La forma formattata appartiene allo strato di presentazione, generata su richiesta dalla lingua del lettore.
- Perché toFixed(2) è in disaccordo con Intl su 1,005?
- Perché arrotondano cose diverse. Il valore in doppia precisione più vicino a 1,005 è pochissimo minore di 1,005, e toFixed arrotonda quel valore binario, quindi produce 1,00. Intl arrotonda il numero decimale che hai chiesto e produce 1,01. La stessa divisione compare su 2,675, dove toFixed dà 2,67 e Intl dà 2,68. Se c'è di mezzo del denaro, usa Intl o un tipo decimale esatto, e non mescolare mai i due nello stesso rapporto.
- Basta un codice di lingua nudo per Intl?
- Non sempre, e il portoghese è l'esempio più chiaro. Chiedere «pt» a Intl dà convenzioni brasiliane — punto per le migliaia, 1.234.567,891 — perché lì sta la maggioranza dei parlanti, mentre «pt-PT» dà la forma europea con spazio unificatore. L'inglese si comporta allo stesso modo, all'inverso, per le convenzioni di data e misura. Se il tuo pubblico per una lingua è un paese preciso, nomina il paese nell'etichetta.
Articoli che potrebbero interessarti
Tutte le guide →Strumenti correlati
Fonti
- Ecma International — ECMA-402: ECMAScript Internationalization API Specification — Intl.NumberFormat
- Unicode Consortium — CLDR — Unicode Common Locale Data Repository (number formats and symbols)
- Unicode Consortium — UTS #35: Unicode Locale Data Markup Language — Part 3, Numbers
- Mozilla — MDN Web Docs — Intl.NumberFormat and formatToParts()
- Mozilla — MDN Web Docs — Number.prototype.toFixed() and Number.parseFloat()
Hai notato un errore in questo articolo?