Formattazione i18n: date, numeri e valute con Intl e i18next (guida 2026)
La traduzione copre le parole. La formattazione consapevole della locale copre tutto ciò che è numerico, ed è lì che l'i18n fatto a mano si sgretola in silenzio: 1,234.56 in tedesco è 1.234,56 e in francese 1 234 567,89, lo stesso giorno è 7/7/2026, 7.7.2026 o 2026/7/7 a seconda della locale, e gli importi in yen non hanno affatto decimali. Concatenare '$' + price.toFixed(2) è un bug nella maggior parte del mondo.
La buona notizia: ognuna di queste regole è già fornita con il browser, e i18next le espone direttamente nelle tue stringhe di traduzione. Questa guida copre le API Intl che fanno il lavoro e il collegamento i18next sopra di esse.
Indice
- Perché formattare a mano non funziona
- La cassetta degli attrezzi Intl
- Numeri, percentuali e notazione compatta
- Valute
- Date, ore e tempi relativi
- La formattazione nelle traduzioni i18next
- Formattatori personalizzati
- Errori comuni
- Gestire le stringhe formattate tra le lingue
- Domande frequenti
Perché formattare a mano non funziona
Prendi un numero, 1234567.89, e rendilo come si aspettano cinque locale reali:
| Locale | Resa |
|---|---|
en-US (inglese, USA) | 1,234,567.89 |
de-DE (tedesco) | 1.234.567,89 |
fr-FR (francese) | 1 234 567,89 |
de-CH (tedesco, Svizzera) | 1'234'567.89 |
en-IN (inglese, India) | 12,34,567.89 |
La resa tedesca scambia il significato di . e , rispetto all'inglese. Il francese raggruppa con spazi (unificatori stretti). L'India raggruppa in lakh e crore, quindi persino le dimensioni dei gruppi differiscono. Le date hanno lo stesso problema (mese/giorno/anno vs giorno.mese.anno vs anno/mese/giorno), e le valute aggiungono la posizione del simbolo e le regole decimali per valuta.
Niente di tutto questo va codificato a mano per lingua. Come le regole dei plurali, è tutto definito nell'Unicode CLDR, ed è già nel tuo runtime.
La cassetta degli attrezzi Intl
L'API di internazionalizzazione ECMAScript (Intl) è fornita con ogni browser moderno e con Node.js, dati CLDR inclusi, quindi non ci sono bundle di locale da installare:
| API | Cosa formatta | Nome del formato i18next |
|---|---|---|
Intl.NumberFormat | numeri, percentuali, unità, notazione compatta, valute | number, currency |
Intl.DateTimeFormat | date e ore | datetime |
Intl.RelativeTimeFormat | «3 giorni fa», «tra 2 ore» | relativetime |
Intl.ListFormat | «a, b e c» | list |
Intl.PluralRules | categorie plurali | alimenta i suffissi _one / _other |
Le prime quattro sono questo articolo. L'ultima è un argomento a sé, trattato nella guida alla pluralizzazione linkata sopra.
Numeri, percentuali e notazione compatta
Intl.NumberFormat gestisce separatori, percentuali, unità fisiche e notazione compatta (quella «da social media»), per locale:
new Intl.NumberFormat('en-US').format(1234567.89) // "1,234,567.89"
new Intl.NumberFormat('de-DE').format(1234567.89) // "1.234.567,89"
new Intl.NumberFormat('en', { style: 'percent' }).format(0.256) // "26%"
new Intl.NumberFormat('de', { style: 'percent' }).format(0.256) // "26 %"
new Intl.NumberFormat('en', { notation: 'compact' }).format(1234567) // "1.2M"
new Intl.NumberFormat('de', { notation: 'compact' }).format(1234567) // "1,2 Mio."
new Intl.NumberFormat('en', { style: 'unit', unit: 'kilometer-per-hour' }).format(88) // "88 km/h"Nota i dettagli che nessuno codificherebbe a mano: il percento tedesco riceve uno spazio (unificatore) prima di %, e il suffisso compatto è una parola specifica della locale, non sempre una lettera.
Valute
La formattazione delle valute è formattazione dei numeri più tre decisioni dipendenti dalla locale: quale simbolo, dove va e quanti decimali usa la valuta.
const price = 1234.5
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)
// "$1,234.50"
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)
// "1.234,50 €"
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
// "¥1,235"La riga dello yen è quella che sorprende: JPY non ha unità minori, quindi Intl arrotonda allo yen intero. price.toFixed(2) avrebbe stampato ¥1234.50, che non esiste.
Una regola da interiorizzare: la valuta viene dalla transazione, la formattazione dalla locale dell'utente. Un utente tedesco che compra in dollari vede dollari, formattati alla tedesca:
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'USD' }).format(price)
// "1.234,50 $"Non derivare mai il codice valuta dalla lingua dell'interfaccia.
Date, ore e tempi relativi
Intl.DateTimeFormat copre l'ordine, i separatori e i nomi di mesi e giorni della settimana. I preset dateStyle / timeStyle scelgono un layout sensato per locale:
const d = new Date('2026-07-07T15:30:00Z')
new Intl.DateTimeFormat('en-US').format(d) // "7/7/2026"
new Intl.DateTimeFormat('de-DE').format(d) // "7.7.2026"
new Intl.DateTimeFormat('ja-JP').format(d) // "2026/7/7"
new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(d)
// "Tuesday, July 7, 2026"
new Intl.DateTimeFormat('de-DE', { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Zurich' }).format(d)
// "7. Juli 2026 um 17:30"La formattazione non converte i fusi orari al posto tuo; senza un'opzione timeZone esplicita, il rendering avviene nel fuso del runtime.
Per stringhe come «3 giorni fa» / «tra 2 ore», Intl.RelativeTimeFormat prende uno scostamento con segno più un'unità:
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
rtf.format(-1, 'day') // "yesterday"
rtf.format(3, 'hour') // "in 3 hours"
new Intl.RelativeTimeFormat('de', { numeric: 'auto' }).format(-1, 'day') // "gestern"E Intl.ListFormat unisce elenchi di parole con i separatori e la congiunzione giusti per lingua:
new Intl.ListFormat('en').format(['HTML', 'CSS', 'JavaScript']) // "HTML, CSS, and JavaScript"
new Intl.ListFormat('de').format(['HTML', 'CSS', 'JavaScript']) // "HTML, CSS und JavaScript"La formattazione nelle traduzioni i18next
Chiamare Intl da soli funziona, ma spezza una frase in frammenti formattati dal codice e frammenti tradotti. i18next risolve il problema: da v21.3, i formattatori Intl sono integrati nell'interpolazione, quindi il segnaposto porta con sé il proprio formato:
{
"downloads_one": "{{count, number}} download",
"downloads_other": "{{count, number}} downloads",
"price": "On sale for {{val, currency(USD)}}",
"updated": "Updated {{val, relativetime}}",
"released": "Released on {{val, datetime}}",
"authors": "By {{val, list}}"
}t('downloads', { count: 1234567 }) // "1,234,567 downloads"
t('price', { val: 42.5 }) // "On sale for $42.50"
t('updated', { val: -3 }) // "Updated 3 days ago"
t('released', { val: new Date() }) // "Released on 7/7/2026"
t('authors', { val: ['Nina', 'Marco', 'Aisha'] }) // "By Nina, Marco, and Aisha"i18next passa la lingua attiva alla giusta API Intl al posto tuo, e le versioni attuali mettono in cache le istanze dei formattatori sottostanti. La chiave downloads mostra come la formattazione si compone con i suffissi plurali _one / _other: count seleziona la forma plurale e viene reso localizzato. E poiché è normale interpolazione di t(), funziona senza modifiche in react-i18next, next-i18next e ogni altro binding.
Le opzioni di formato vanno inline nella chiave, oppure per chiamata via formatParams:
{
"revenue": "Revenue: {{val, currency}}",
"score": "{{val, number(minimumFractionDigits: 2)}} points"
}t('revenue', {
val: 12345.67,
formatParams: { val: { currency: 'EUR' } }
})
// active language en → "Revenue: €12,345.67"
// active language de → "Umsatz: 12.345,67 €"Sia la stringa sia la formattazione seguono la lingua attiva; nel tuo componente non cambia nulla. Mantieni le risorse come semplice .json (riferimento del formato), e nota che questa è formattazione a livello di interpolazione: per come si confrontano interi formati di messaggi (ICU, Fluent, gettext, …) vedi I18N in the Multiverse of Formats.
Formattatori personalizzati
Tutto ciò che Intl non copre puoi registrarlo tu stesso e usarlo come un formato integrato:
i18next.services.formatter.add('filesize', (value, lng) => {
const mb = value / (1024 * 1024)
return `${new Intl.NumberFormat(lng, { maximumFractionDigits: 1 }).format(mb)} MB`
}){ "attachment": "Attachment size: {{val, filesize}}" }Se il lavoro di setup è per locale (come la costruzione di quell'Intl.NumberFormat), le versioni più recenti di i18next offrono anche formatter.addCached, che costruisce il formattatore una volta per lingua e lo riusa. I progetti iniziati prima di i18next v21 potrebbero configurare ancora la funzione legacy interpolation.format(value, format, lng); continua a funzionare, ma i formati integrati ormai coprono la maggior parte di ciò per cui veniva usata (tipicamente wrapper di Moment.js o Numeral.js).
Errori comuni
- Concatenare i simboli.
'$' + price.toFixed(2)sbaglia simbolo, posizione, separatori e numero di decimali (JPY non ne ha). Usastyle: 'currency', sempre. - Nessun argomento locale.
toLocaleString()onew Intl.NumberFormat()senza locale usa la locale del runtime, non la lingua del tuo utente, una fonte classica di discrepanze di hydration server/client. I formati integrati di i18next passano sempre la lingua attiva. - Derivare la valuta dalla lingua dell'interfaccia. La valuta è un dato della transazione. Formatta
USDcon separatori tedeschi per un utente tedesco; non passare in silenzio aEUR. - Digitare i formati nelle traduzioni. Se i traduttori scrivono pattern in stile
1.234,56nelle stringhe, ogni modifica al testo rischia un bug di formato. Lascia il segnaposto grezzo ({{val, number}}) nella stringa e lascia il resto a CLDR. - Confondere la formattazione con i fusi orari.
Intl.DateTimeFormatrende nel fuso del runtime a meno che tu non passitimeZone. La formattazione localizza la visualizzazione, non sposta l'orologio. - Dati ICU mancanti sul server. Le build ufficiali di Node.js includono l'ICU completo da v13, ma le build personalizzate o
small-icuricadono in silenzio sui formati inglesi, un bug che si vede solo lato server.
Gestire le stringhe formattate tra le lingue
La formattazione in sé è gratis, il lavoro lo fa CLDR. Ciò che si rompe nella pratica sono i segnaposto. Un traduttore che ridigita {{val, currency}} come {{val currency}}, o traduce la parola relativetime in francese, rompe esattamente una lingua in produzione, e nessun test scritto sulle stringhe inglesi lo intercetterà.
È qui che un sistema di gestione delle traduzioni si guadagna il suo posto. Locize è costruito attorno al formato dei messaggi di i18next:
- I segnaposto come
{{val, datetime}}restano visibili nell'editor; i traduttori li spostano in posizione invece di ridigitarli. - La traduzione automatica e con AI è consapevole del formato i18next, quindi i segnaposto sopravvivono intatti al viaggio di andata e ritorno.
- Le chiavi aggiunte via saveMissing arrivano con i loro segnaposto, pronte da tradurre.
Prendi la formattazione da Intl, tieni le stringhe in JSON, e aggiungere una locale diventa un compito di traduzione, non di ingegneria, che è l'obiettivo stesso dell'i18n.
Domande frequenti
Da i18next v21.3 la formattazione è integrata: scrivi {{val, number}}, {{val, datetime}}, {{val, currency(USD)}}, {{val, relativetime}} o {{val, list}} direttamente nella stringa di traduzione. i18next chiama l'API Intl corrispondente (Intl.NumberFormat, Intl.DateTimeFormat, …) con la lingua attiva, quindi l'output segue automaticamente la locale.
Usa {{val, currency(USD)}} nella stringa di traduzione, oppure mantieni la stringa generica ({{val, currency}}) e passa il codice valuta al momento della chiamata via formatParams. Il codice valuta deve venire dai dati della transazione, non dalla lingua dell'interfaccia: un utente tedesco può pagare in USD, che viene reso come 1.234,50 $ con separatori tedeschi e simbolo del dollaro.
Intl è l'API di internazionalizzazione ECMAScript integrata in ogni browser moderno e in Node.js. Fornisce formattatori consapevoli della locale basati sui dati CLDR: Intl.NumberFormat (numeri, valute, percentuali, unità), Intl.DateTimeFormat (date e ore), Intl.RelativeTimeFormat («3 giorni fa»), Intl.ListFormat (elenchi di parole) e Intl.PluralRules (categorie plurali). Nessuna libreria aggiuntiva e nessun file di locale da distribuire.
Per formattare l'output: di solito no. Intl.DateTimeFormat e Intl.NumberFormat coprono la visualizzazione consapevole della locale senza distribuire bundle di locale. Librerie come date-fns o Day.js restano utili per l'aritmetica delle date e il parsing, e Moment.js è in modalità manutenzione; la sua stessa documentazione raccomanda alternative basate su Intl.
La causa più comune è chiamare toLocaleString() o new Intl.NumberFormat() senza argomento locale: viene usata la locale del runtime, non la lingua scelta dall'utente, il che causa anche discrepanze di hydration server/client. I formattatori integrati di i18next passano sempre la lingua attiva. Verifica inoltre che il valore sia davvero un numero o un oggetto Date, non una stringa.
Intl.RelativeTimeFormat formatta uno scostamento numerico più un'unità: format(-3, "day") dà «3 giorni fa», format(2, "hour") dà «tra 2 ore», e l'opzione numeric: "auto" trasforma uno scostamento di 1 giorno in «ieri» o «domani». In i18next scrivi {{val, relativetime}} e passi lo scostamento; il calcolo dello scostamento da un timestamp resta nel tuo codice.
Sì. Registralo con i18next.services.formatter.add("myformat", (value, lng, options) => …) e usa {{val, myformat}} nelle stringhe di traduzione. Per il lavoro di setup per locale esiste formatter.addCached, che costruisce il formattatore una volta per lingua e lo riusa, lo stesso caching che i18next applica ai suoi formati Intl integrati.
No. Separatori, posizione del simbolo e ordine delle date vengono da CLDR via Intl, per locale, quindi nessuno deve digitarli. I traduttori spostano solo i segnaposto come {{val, datetime}} nella posizione giusta della frase. Un sistema di gestione delle traduzioni come Locize mantiene visibili quei segnaposto e traduce automaticamente attorno a essi senza romperli.