Intl.NumberFormat e Intl.DateTimeFormat: formattare senza librerie

Quasi ogni progetto front-end che mi passa davanti ha, nel package.json, una libreria per
formattare i numeri e una per le date. Poi si va a vedere a cosa servivano: stampare 1.234,56 €
in una tabella e 27 settembre 2026 sotto il titolo di un articolo. Per quello non serve niente.
Il runtime lo sa fare, e lo fa meglio del codice scritto a mano, perché non è codice: sono dati.
I dati non stanno nel tuo codice
Intl è standard — è ECMA-402 — ma lo standard descrive le API, non
il contenuto. Separatori, nomi dei mesi, ordine dei campi arrivano da un database di localizzazione.
La specifica è esplicita su questo punto: «Although use of CLDR data is recommended for consistency
between implementations, it is not required». Node e V8 ci arrivano attraverso ICU: «Node.js and the
underlying V8 engine use International Components for Unicode (ICU) to implement these features in
native C/C++ code» (documentazione Node).
Vale la pena guardarli, quei dati. Per l’italiano CLDR registra il separatore decimale ,, quello
delle migliaia . e il pattern della valuta #,##0.00 ¤. Il punto e la virgola dentro un pattern
non sono caratteri veri: UTS #35 dice che
«the thousands separator and decimal separator in patterns are always ASCII ‘,’ and ‘.’. They are
substituted by the code with the correct local values», e che ¤ è «replaced by the localized
currency symbol for the currency being formatted».
Tradotto: in italiano il simbolo della valuta va dopo il numero, preceduto da uno spazio. È
esattamente la cosa che un '€ ' + n.toFixed(2) scritto di corsa sbaglia, e che nessuno rilegge
finché non la vede un cliente su un preventivo.
Numeri
const eur = new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
});
eur.format(1234.5);
Quattro cose da sapere sulle opzioni, prese dalla
pagina del costruttore:
stylevale"decimal"(default),"currency","percent","unit". Con"currency"la
proprietàcurrencyè obbligatoria: senza, parte unTypeError— «thrown if theoptions.style
property is set to “unit” or “currency”, and no value has been set for the corresponding property».- Le cifre decimali hanno default diversi per stile. Per un numero normale il massimo è «the larger
ofminimumFractionDigitsand3»:format(1234.56789)perde cifre e nessuno te lo dice. Per
una valuta il default è il numero di sotto-unità della ISO 4217 — due, per l’euro. "percent"moltiplica per cento: nel pattern CLDR il%significa «multiply by 100 and show
as percentage». Gli passi0.2, non20.roundingModeha default"halfExpand", cioè «ties away from 0». Se stai facendo contabilità,
è un valore che vuoi decidere tu invece che ereditare.
Il costo vero è costruire il formatter dentro il ciclo
Qui sta la differenza tra usare Intl e usarlo bene. MDN lo scrive nero su bianco nella pagina di
toLocaleString:
Every time
toLocaleStringis called, it has to perform a search in a big database of localization
strings, which is potentially inefficient. When the method is called many times with the same
arguments, it is better to create anIntl.NumberFormatobject and use itsformat()method,
because aNumberFormatobject remembers the arguments passed to it and may decide to cache a
slice of the database.
Un n.toLocaleString('it-IT', opzioni) dentro il map di una tabella da duemila righe è duemila
ricerche nel database. Il formatter si costruisce una volta sola, fuori dal ciclo:
const eur = new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" });
const righe = ordini.map((o) => eur.format(o.totale));
E si può abbreviare: il getter format «is bound to the Intl.NumberFormat from which it was
obtained, so it can be passed directly to Array.prototype.map».
Un dettaglio che risolve un problema vero: format() accetta stringhe e BigInt, e in quel caso
«will use the exact value that the string represents, avoiding loss of precision». Un importo che
arriva dal database come DECIMAL si formatta così com’è, senza passare da un float.
Date
const data = new Intl.DateTimeFormat("it-IT", { dateStyle: "long" });
dateStyle e timeStyle accettano "full", "long", "medium", "short". La nota di MDN è la
regola che si viola più spesso: «dateStyle and timeStyle can be used with each other, but not
with other date-time component options (e.g., weekday, hour, month, etc.)». O lo stile pronto,
o i componenti uno per uno. Insieme non funziona, e non è un errore rumoroso.
I pattern italiani che CLDR mette dietro quei nomi sono EEEE d MMMM y, d MMMM y, d MMM y e
dd/MM/yy, con gli orari da HH:mm:ss zzzz a HH:mm. Due conseguenze pratiche: la forma corta
italiana ha l’anno a due cifre, quindi guarda cosa produce prima di metterla su un documento; e
gli orari italiani sono su ventiquattro ore, quindi hour12: false non serve. I nomi dei mesi, nei
dati, sono minuscoli — gennaio, non Gennaio — che è poi la regola dell’italiano e la prima cosa
che sbaglia un array scritto a mano.
L’opzione che dimentichiamo tutti è timeZone: «the default is the runtime’s time zone». Su un
server è il fuso del server, che non è quello di chi legge. Se il pubblico è in Italia e la macchina
sta altrove, il fuso si scrive, con il nome IANA: Europe/Rome, Atlantic/Canary.
Tempo relativo e liste
Due API che quasi nessuno usa e che cancellano parecchio codice. Intl.RelativeTimeFormat prende un
numero e un’unità — negativo passato, positivo futuro — e con numeric: "auto" usa la forma
idiomatica invece del numero: sugli esempi MDN in inglese, format(-1, "day") diventa "yesterday"
e format(0, "second") diventa "now". Il default è "always", quindi la forma idiomatica va
chiesta.
Intl.ListFormat monta l’elenco con la congiunzione giusta: type vale "conjunction" (default),
"disjunction" o "unit", e l’esempio della documentazione mostra la differenza —
Motorcycle, Bus and Car contro Motorcycle, Bus or Car. Sono le due righe di join(', ') più
l’ultimo pezzo attaccato a mano che stanno in ogni progetto.
Cosa evitare
- Costruire il formatter dentro il ciclo. È l’unico errore di questa lista che si paga in
millisecondi. - Mescolare
dateStyleconweekday,month,hour. Non è supportato e fallisce in silenzio. - Trattare la stringa in uscita come un contratto. La specifica non impone CLDR e i dati ICU
cambiano fra versioni. Non farci asserzioni nei test e non riparsarla: se ti servono i pezzi c’è
formatToParts(), che «returns an Array of objects representing the number string in parts». - Dare per scontato che il server sappia l’italiano. Un Node compilato
small-icuhaIntl
«partial (English-only)»; la documentazione mostra il caso esatto,Intl.DateTimeFormat('es', {che stampa «either “M01” or “January” on small-icu» quando dovrebbe dire
month: 'long' })
enero. I binari ufficiali sonofull-icu, ma un’immagine ritagliata o un pacchetto di
distribuzione possono non esserlo. Si verifica prima di accorgersene in produzione, con
Intl.DateTimeFormat.supportedLocalesOf(["it"])o guardandoresolvedOptions().
Nessuna di queste API è nuova e nessuna richiede una dipendenza. Tolgono righe invece di
aggiungerne, e tolgono la categoria di bug più imbarazzante che ci sia: il prezzo scritto male sul
sito di qualcun altro.
Se hai un gestionale o un sito che stampa importi e date e non sei sicuro che lo faccia nel modo
giusto, scrivimi: è un controllo da poco, e si vede subito.
