Aller au contenu
Allin

Formater les nombres pour six langues : séparateurs, monnaie et le retour à la valeur

Publié le 03/10/2025 · 14 min de lecture · Outils texte & langage

Daniel Okonkwo

Daniel OkonkwoDéveloppeur front-end et rédacteur Tech chez Allin

Performance web · Formats de fichiers

Vérifié à partir de 5 sources

Voir le profil
En bref

La même quantité s'écrit 1 234 567,891 en français, 1.234.567,891 en espagnol, allemand et italien, et 1,234,567.891 en anglais — et les espaces de la version française ne sont pas des espaces que l'on peut taper. En lançant Intl.NumberFormat sur Node 26 avec ICU 78, on obtient U+202F NARROW NO-BREAK SPACE comme séparateur de groupes en français et U+00A0 NO-BREAK SPACE en portugais européen, tous deux invisibles et tous deux fatals à un analyseur qui attend une espace ordinaire ou une virgule. Trois des six locales refusent aussi de grouper les nombres à quatre chiffres : l'espagnol, le portugais européen et l'italien écrivent 1234 sans séparateur mais 10.000 avec, car CLDR fixe leur nombre minimal de chiffres groupés à deux. La disposition monétaire se scinde pareillement : l'anglais place le symbole avant les chiffres sans rien entre, tandis que les cinq autres le placent après le nombre avec une espace insécable. Rien de tout cela ne se défait avec Number.parseFloat, qui renvoie 1 pour la chaîne anglaise et un très plausible 1,234 pour l'allemande. Le seul analyseur sûr demande à Intl.NumberFormat.formatToParts quels caractères cette locale emploie réellement, puis retire le séparateur de groupes avant de convertir le séparateur décimal — dans cet ordre, car l'inverse multiplie la valeur par mille.

1 234,56 et 1,234.56 sont le même nombre, et les confondre change la valeur que lit un lecteur. Nous avons lancé Intl.NumberFormat pour les six locales du site et imprimé chaque séparateur — dont l'invisible qu'emploie le français — puis mesuré pourquoi parseFloat ne peut rien défaire.

Le même nombre, six graphies

Nous avons pris une valeur, 1234567.891, et l'avons formatée avec Intl.NumberFormat dans les six locales que publie ce site. L'anglais donne 1,234,567.891. L'espagnol, l'allemand et l'italien donnent tous 1.234.567,891 — virgule et point ont échangé leurs rôles, si bien qu'un lecteur appliquant ses habitudes anglaises lit un nombre mille fois trop petit sans s'en apercevoir. Le français donne 1 234 567,891 et le portugais européen aussi, mais avec un caractère invisible différent pour l'espacement.

Ce n'est pas un détail de présentation. Une colonne de tableur collée d'une convention vers un système qui en attend une autre change en silence chacune de ses valeurs, et la panne est invisible car les deux graphies sont des nombres légaux. La règle à intérioriser : un nombre formaté est un texte à propos d'une valeur, dans une langue donnée, et la langue doit voyager avec lui.

Deux autres conventions méritent d'être connues car elles apparaissent dans les données européennes. L'allemand de Suisse emploie l'apostrophe : de-CH formate la même valeur en 1'234'567.891, avec un point pour la décimale. Et une étiquette de langue nue n'équivaut pas à une étiquette langue-et-région : demander « pt » à Intl produit les conventions brésiliennes, 1.234.567,891, tandis que « pt-PT » produit la forme européenne à espaces. Si ton public lusophone est européen, l'étiquette nue est discrètement fausse.

Le séparateur que l'on ne voit pas

Le séparateur de groupes français dans les données CLDR actuelles n'est pas la barre d'espace. Demander à Intl.NumberFormat les parties d'un nombre français renvoie U+202F NARROW NO-BREAK SPACE. Le portugais européen emploie U+00A0 NO-BREAK SPACE, un caractère différent de largeur différente. Les deux ressemblent exactement à une espace dans tout éditeur, tout terminal et tout navigateur, et ni l'un ni l'autre n'en est une.

Les conséquences sont tout à fait pratiques. Une règle de validation écrite /^[\d ,]+$/ rejette un nombre français correctement formaté, car le caractère qu'il contient n'est pas l'espace de la classe. Un rechercher-remplacer qui supprime les espaces laisse le séparateur intact. Un export CSV produit des cellules qu'un tableur lit comme du texte et non comme des nombres. Et copier le nombre d'une page pour le coller dans une calculatrice donne une erreur que personne ne sait expliquer, car le caractère fautif est invisible des deux côtés du collage.

La bonne démarche est de ne jamais deviner le séparateur. Intl.NumberFormat(locale).formatToParts(12345.6) renvoie un petit tableau où une entrée a le type « group » et une autre le type « decimal », et leurs valeurs sont exactement les caractères qu'emploie cette locale aujourd'hui. Lis-les à l'exécution et ton code continue de fonctionner quand CLDR change, ce qui arrive : le séparateur français était une espace insécable ordinaire dans les données plus anciennes, avant que l'étroite ne la remplace.

Trois locales ne groupent pas les nombres à quatre chiffres

Formate 1234 dans les six locales et les résultats ne s'alignent pas. L'anglais donne 1,234, le français 1 234 et l'allemand 1.234 — mais l'espagnol, le portugais européen et l'italien donnent tous 1234, sans aucun séparateur. Monte à 10000 et tous groupent : 10,000, 10 000, 10.000 et 10.000 respectivement. La bascule se produit entre quatre et cinq chiffres.

La règle sous-jacente est un réglage CLDR appelé nombre minimal de chiffres groupés, qui indique combien de chiffres doivent se trouver à gauche du premier séparateur pour que le groupement vaille la peine. L'espagnol, le portugais européen et l'italien le fixent à deux ; l'anglais, le français et l'allemand à un. Il existe parce qu'un nombre à quatre chiffres est souvent, dans ces langues, une année ou une référence, et se lit mieux non coupé. Si tu veux le groupement malgré tout — un tableau de montants dont les colonnes doivent s'aligner — passe useGrouping: "always" et l'espagnol renvoie 1.234. L'option inverse, useGrouping: "min2", fait que l'anglais se comporte comme l'espagnol et imprime 1234.

Monnaie et pourcentage : où se place le symbole

Pour un montant de 1234,50, l'anglais formate $1,234.50 — symbole d'abord, sans espace, groupement dès le millier. Les cinq locales européennes placent toutes le symbole en dernier, et toutes une espace insécable avant lui : le français produit le montant avec des espaces fines insécables dans les chiffres, puis une espace insécable et le symbole € ; l'allemand produit 1.234,50 suivi de cette espace et du symbole ; l'espagnol, le portugais et l'italien produisent 1234,50 suivi de la même chose, non groupé à cause de la règle des quatre chiffres ci-dessus.

Cette espace insécable avant le symbole est un vrai caractère, et elle est là exprès : elle empêche un retour à la ligne de séparer le montant de son unité. Elle casse aussi les mêmes analyseurs naïfs que le séparateur de groupes, et fait échouer sans raison visible la comparaison de chaînes avec une valeur attendue écrite à la main dans les tests. Compare la sortie formatée avec formatToParts, pas par égalité avec un littéral que tu as tapé.

Le pourcentage a son propre piège, et il ne concerne pas les séparateurs. style: "percent" multiplie par 100 avant de formater. Passer 12,34 parce qu'on a déjà converti le rapport donne 1 234 % — un nombre cent fois trop grand, imprimé sans broncher. Passe le rapport brut, 0,1234, et tu obtiens 12,34 %. L'espacement diffère aussi : l'anglais écrit 12.3% sans écart, le français, l'espagnol et l'allemand insèrent une espace insécable avant le signe, le portugais et l'italien non.

Chiffres décimaux, arrondi et les pièges des options

La valeur par défaut pour un nombre simple est un maximum de trois décimales : formater 1,23456 donne 1,235 et le reste disparaît. Pour une monnaie, la valeur par défaut vient de la monnaie elle-même : deux décimales pour le dollar et l'euro, zéro pour le yen, trois pour le dinar tunisien. C'est en général ce que l'on veut, et il vaut mieux savoir que cela se produit plutôt que supposer deux partout.

Les deux options qui génèrent des tickets sont minimumFractionDigits et maximumFractionDigits. Fixer le minimum au-dessus du maximum lève une RangeError plutôt que de tronquer, ce qui a au moins le mérite d'être bruyant. Fixer le minimum seul relève silencieusement le maximum pour correspondre : minimumFractionDigits: 4 sur 1,23456789 imprime 1,2346 et non les deux décimales que tu attendais peut-être d'ailleurs dans le code.

L'arrondi a une valeur par défaut à connaître. Intl arrondit à la moitié en s'éloignant de zéro : 2,5 devient 3 et -0,5 devient -1 à zéro décimale ; passe roundingMode: "halfEven" et 2,5 devient 2, ce que veulent généralement la comptabilité et la statistique. Et Intl n'est pas toFixed : arrondir 1,005 à deux décimales donne 1,01 avec Intl et 1,00 avec toFixed, et arrondir 2,675 donne 2,68 avec Intl et 2,67 avec toFixed. La différence : toFixed arrondit le double binaire, dont la valeur est très légèrement sous la décimale écrite, tandis qu'Intl arrondit la décimale voulue.

La notation compacte, qui est une traduction et non une abréviation

Régler notation: "compact" transforme 1234567 en 1.2M en anglais. Les cinq autres locales n'emploient pas toutes M : le français donne le montant avec un M, l'espagnol et le portugais aussi, l'allemand donne Mio. et l'italien Mln. En forme longue les différences sont plus nettes : 1.2 million, 1,2 million, 1,2 millones, 1,2 milhões, 1,2 Millionen, 1,2 milioni.

Les milliers sont encore plus disparates. L'anglais donne 1.5K pour 1500, le français 1,5 k avec un k minuscule, l'espagnol et le portugais 1,5 mil, l'italien 1,5K — et l'allemand donne 1500, inchangé, car CLDR n'a pas de forme compacte courte pour les milliers en allemand. Si ton tableau de bord suppose que toutes les locales raccourcissent pareil, la colonne allemande sera plus large que les autres et il n'y a rien à configurer là-dessus.

Pourquoi parseFloat ne peut rien défaire de tout cela

toLocaleString est une fonction à sens unique. Nous avons formaté 1234567,891 dans chacune des six locales et redonné le résultat tel quel à Number.parseFloat. L'anglais a renvoyé 1. Le français a renvoyé 1. Le portugais a renvoyé 1. L'espagnol, l'allemand et l'italien ont renvoyé 1,234. Aucun n'a renvoyé la valeur d'origine, et les trois derniers sont les dangereux, car 1,234 est un nombre parfaitement plausible qu'aucune validation ne rejettera.

Number() est au moins honnête : il renvoie NaN pour les six, car aucun n'est un littéral numérique valide. Cela fait de Number() un meilleur garde-fou si tu vérifies seulement qu'une chaîne est un nombre machine nu, et cela le rend inutile comme analyseur de tout ce qu'un humain a lu.

L'ordre du retrait compte plus qu'on ne le croit. Prends la chaîne allemande 1.234.567,891. Retire d'abord les points, puis transforme la virgule en point : tu obtiens 1234567.891 — correct. Transforme d'abord la virgule en point puis retire les points : tu obtiens 1234567891, mille fois trop grand et pourtant un entier plausible. Ce sont deux fonctions de deux lignes et une seule est juste.

Nous avons écrit la version pilotée par la locale et l'avons testée sur les six : lire les caractères de groupe et de décimale depuis formatToParts, supprimer chaque occurrence du caractère de groupe, remplacer le caractère décimal par un point, jeter tout ce qui reste et n'est ni chiffre ni signe, puis convertir. Elle a reproduit exactement la valeur d'origine dans les six, et elle a aussi analysé les six chaînes monétaires, symboles et espaces insécables compris, sans aucun code propre à une locale.

1234567,891
Sortie d'Intl.NumberFormat pour 1234567,891 et pour un montant de 1234,50 — Node 26.3.0, ICU 78.3
Locale1234567,891Séparateur de groupesSéparateur décimalMontant, 1234,50
en-US1,234,567.891VirgulePoint$1,234.50 (symbole d'abord)
fr-FR1 234 567,891U+202F espace fine insécableVirgule1 234,50 € (symbole en dernier)
es-ES1.234.567,891PointVirgule1234,50 € (pas de groupement sous 10 000)
pt-PT1 234 567,891U+00A0 espace insécableVirgule1234,50 € (pas de groupement sous 10 000)
de-DE1.234.567,891PointVirgule1.234,50 € (symbole en dernier)
it-IT1.234.567,891PointVirgule1234,50 € (pas de groupement sous 10 000)
Formateur de nombresAjoute des séparateurs de milliers et fixe les décimales des nombres de ton texte.Essayer l'outil

Questions fréquentes

Quel séparateur le français emploie-t-il réellement pour les milliers ?
U+202F NARROW NO-BREAK SPACE, dans les données CLDR courantes au moment où nous écrivons — nous l'avons lu directement dans formatToParts sur Node 26 avec ICU 78. Ce n'est ni la barre d'espace ni U+00A0, qu'emploie le portugais européen. D'anciennes versions de CLDR utilisaient aussi U+00A0 pour le français : tout code qui en fige un cassera lors d'une mise à jour du moteur. Lis le séparateur à l'exécution et la question cesse de compter.
Pourquoi 1234 s'imprime-t-il sans séparateur en espagnol et en italien ?
Parce que CLDR fixe à deux le nombre minimal de chiffres groupés pour ces locales : le groupement ne commence qu'à partir de deux chiffres avant le premier séparateur. Ainsi 1234 s'écrit nu et 10.000 est groupé. C'est délibéré : dans ces langues, les nombres à quatre chiffres sont souvent des années ou des références. Passe useGrouping: "always" s'il te faut le séparateur malgré tout, par exemple pour garder une colonne de chiffres alignée.
Puis-je utiliser toLocaleString et parseFloat en tandem ?
Non, et l'échec est silencieux. Nous avons formaté 1234567,891 dans six locales et lancé parseFloat sur chaque résultat : trois ont renvoyé 1 et trois ont renvoyé 1,234. Aucun n'a renvoyé l'original. Number() renvoie au moins NaN pour les six : il échoue bruyamment. Le formatage est pour l'affichage, et l'analyse exige son propre chemin de code piloté par les séparateurs de la locale.
Faut-il stocker des nombres formatés ou bruts ?
Bruts, toujours, et formate au dernier moment possible. Une valeur stockée 1234567.891 est sans ambiguïté et l'arithmétique fonctionne dessus. Une chaîne stockée 1.234.567,891 emporte une langue dont il faut ensuite se souvenir, ne se trie pas numériquement et transforme chaque calcul en analyse. La forme formatée appartient à la couche d'affichage, générée à la demande depuis la locale du lecteur.
Pourquoi toFixed(2) est-il en désaccord avec Intl sur 1,005 ?
Parce qu'ils arrondissent des choses différentes. La valeur en double précision la plus proche de 1,005 est très légèrement inférieure à 1,005, et toFixed arrondit cette valeur binaire : il produit 1,00. Intl arrondit le nombre décimal demandé et produit 1,01. Le même écart apparaît sur 2,675, où toFixed donne 2,67 et Intl 2,68. Si de l'argent est en jeu, emploie Intl ou un type décimal exact, et ne mélange jamais les deux dans un même rapport.
Un code de langue nu suffit-il à Intl ?
Pas toujours, et le portugais en est l'exemple le plus net. Demander « pt » à Intl donne les conventions brésiliennes — un point pour les milliers, 1.234.567,891 — car c'est là que se trouve la majorité des lusophones, tandis que « pt-PT » donne la forme européenne à espace insécable. L'anglais se comporte de même, en sens inverse, pour les conventions de date et de mesure. Si ton public pour une langue est un pays précis, nomme le pays dans l'étiquette.

Articles qui pourraient t'intéresser

Tous les guides
ExplicationCompter les mots est ambigu, et chaque outil répond autrementUn compte de mots est une définition, pas une mesure. Nous avons compté le même paragraphe de quatre façons et obtenu 25, 28, 33 et 38 — puis compté 50 000 caractères de prose ordinaire et obtenu un accord à 4,5 % près. L'écart tient entièrement aux composés, aux chiffres et aux URL.ExplicationLes emoji sont plus difficiles qu'ils n'en ont l'air : pourquoi « il suffit de les retirer » n'a pas de réponse en une ligneUn emoji visible peut valoir un point de code ou quatorze unités UTF-16. Nous avons lancé trois expressions régulières répandues sur une vraie phrase : chacune a cassé différemment, l'une a supprimé les chiffres. Voici pourquoi, quelle propriété Unicode répond à quelle question, et la règle de groupes de graphèmes qui marche vraiment.TutorielFiltrer des lignes selon un motif, sans ligne de commandeC'est grep pour ceux qui n'utilisent pas grep, avec une différence de taille : la recherche est une sous-chaîne littérale, si bien qu'une vraie expression régulière renvoie une zone vide sans message d'erreur. Chaque affirmation a été vérifiée en exécutant l'outil.ExplicationOù une ligne peut se couper : l'algorithme Unicode derrière chaque paragraphe justifié« Couper aux espaces » échoue dans la plupart des systèmes d'écriture. UAX #14 donne à chaque caractère une classe de coupure ; nous avons cherché les nôtres dans Unicode 17.0.0 et exécuté une implémentation conforme sur espaces insécables, traits d'union conditionnels, espaces de largeur nulle, URL, japonais et thaï.GuideConvertir entre formats de liste sans perdre de données : les règles de guillemets que personne ne litPasser d'une liste à retours à la ligne à une liste à virgules est trivial jusqu'à ce qu'un élément contienne une virgule. Les règles de guillemets de la RFC 4180, pourquoi un champ CSV peut contenir un saut de ligne, pourquoi les tableurs européens utilisent le point-virgule, et ce qu'un élément vide fait à l'aller-retour — chaque cas exécuté et imprimé.GuideRetirer le Markdown : ce que le texte brut perd, et ce qu'une regex se trompe à faireUn lien devient un texte dont la destination a disparu, une liste imbriquée perd sa hiérarchie, un tableau devient une file de mots. Puis la moitié technique : le markdown n'a pas de spécification unique, et un nettoyeur à base de regex abîme un nom de fichier, un signe de multiplication et l'intérieur d'un bloc de code — le tout confronté à un vrai analyseur.

Outils similaires

Sources

Tu as repéré une erreur dans cet article ?