Ir al contenido
OneKitly

Formatear números para seis idiomas: separadores, moneda y la vuelta al valor

Publicado el 3/10/2025 · 14 min de lectura · Herramientas de texto e idioma

Daniel Okonkwo

Daniel OkonkwoDesarrollador front-end y redactor de Tecnología en OneKitly

Rendimiento web · Formatos de archivo

Verificado con 5 fuentes

Ver perfil
En resumen

La misma cantidad se escribe 1.234.567,891 en español, alemán e italiano, 1 234 567,891 en francés y 1,234,567.891 en inglés, y los espacios de la versión francesa no son espacios que puedas teclear. Al ejecutar Intl.NumberFormat en Node 26 con ICU 78 aparece U+202F NARROW NO-BREAK SPACE como separador de grupos del francés y U+00A0 NO-BREAK SPACE como el del portugués europeo, ambos invisibles y ambos fatales para un analizador que espera un espacio normal o una coma. Tres de las seis locales además se niegan a agrupar números de cuatro cifras: el español, el portugués europeo y el italiano escriben 1234 sin separador pero 10.000 con él, porque CLDR fija su mínimo de cifras agrupadas en dos. La disposición monetaria se parte igual: el inglés pone el símbolo delante de las cifras sin nada en medio, mientras que las otras cinco lo ponen tras el número con un espacio duro. Nada de esto se deshace con Number.parseFloat, que devuelve 1 para la cadena inglesa y un plausible 1,234 para la alemana. El único análisis seguro pregunta a Intl.NumberFormat.formatToParts qué caracteres usa realmente esa locale y luego quita el separador de grupos antes de convertir el decimal: en ese orden, porque al revés multiplica el valor por mil.

1.234,56 y 1,234.56 son el mismo número, y confundirlos cambia el valor que lee un lector. Ejecutamos Intl.NumberFormat para las seis locales del sitio e imprimimos cada separador —incluido el invisible que usa el francés— y luego medimos por qué parseFloat no puede deshacer nada de eso.

El mismo número, seis grafías

Tomamos un valor, 1234567.891, y lo formateamos con Intl.NumberFormat en las seis locales en las que publica este sitio. El inglés da 1,234,567.891. El español, el alemán y el italiano dan todos 1.234.567,891: coma y punto han intercambiado papeles, de modo que quien aplique costumbres inglesas lee un número mil veces menor sin darse cuenta. El francés da 1 234 567,891 y el portugués europeo también, pero con un carácter invisible distinto haciendo el espaciado.

No es un detalle de presentación. Una columna de hoja de cálculo pegada de una convención a un sistema que espera otra cambia en silencio todos sus valores, y el fallo es invisible porque ambas grafías son números legales. La regla que conviene interiorizar: un número formateado es un texto acerca de un valor, en un idioma concreto, y el idioma tiene que viajar con él.

Dos convenciones más merecen conocerse porque aparecen en datos europeos. El alemán de Suiza usa apóstrofo: de-CH formatea el mismo valor como 1'234'567.891, con punto para el decimal. Y una etiqueta de idioma pelada no equivale a una de idioma y región: pedirle «pt» a Intl produce convenciones brasileñas, 1.234.567,891, mientras que «pt-PT» produce la forma europea con espacios. Si tu público lusófono es europeo, la etiqueta pelada está silenciosamente mal.

El separador que no se ve

El separador de grupos francés en los datos CLDR actuales no es la barra espaciadora. Pedir a Intl.NumberFormat las partes de un número francés devuelve U+202F NARROW NO-BREAK SPACE. El portugués europeo usa U+00A0 NO-BREAK SPACE, un carácter distinto de anchura distinta. Ambos parecen exactamente un espacio en cualquier editor, terminal y navegador, y ninguno lo es.

Las consecuencias son del todo prácticas. Una regla de validación escrita /^[\d ,]+$/ rechaza un número francés correctamente formateado, porque el carácter que contiene no es el espacio de la clase. Un buscar-y-reemplazar que quita espacios deja el separador intacto. Una exportación CSV produce celdas que la hoja de cálculo lee como texto y no como números. Y copiar el número de una página para pegarlo en una calculadora da un error que nadie sabe explicar, porque el carácter culpable es invisible a ambos lados del pegado.

Lo correcto es no adivinar nunca el separador. Intl.NumberFormat(locale).formatToParts(12345.6) devuelve un pequeño arreglo en el que una entrada tiene el tipo «group» y otra el tipo «decimal», y sus valores son exactamente los caracteres que esa locale usa hoy. Léelos en tiempo de ejecución y tu código seguirá funcionando cuando CLDR cambie, cosa que ocurre: el separador francés era un espacio duro normal en datos más antiguos, antes de que lo sustituyera el estrecho.

Tres locales no agrupan los números de cuatro cifras

Formatea 1234 en las seis locales y los resultados no cuadran. El inglés da 1,234, el francés 1 234 y el alemán 1.234, pero el español, el portugués europeo y el italiano dan todos 1234, sin separador alguno. Sube a 10000 y todos agrupan: 10,000, 10 000, 10.000 y 10.000 respectivamente. El cambio ocurre entre las cuatro y las cinco cifras.

La regla que hay detrás es un ajuste de CLDR llamado mínimo de cifras agrupadas, que dice cuántas cifras deben quedar a la izquierda del primer separador para que agrupar valga la pena. El español, el portugués europeo y el italiano lo fijan en dos; el inglés, el francés y el alemán en uno. Existe porque un número de cuatro cifras en esas lenguas suele ser un año o una referencia y se lee mejor sin cortar. Si aun así necesitas la agrupación —una tabla de importes cuyas columnas deben alinearse— pasa useGrouping: "always" y el español devuelve 1.234. La opción inversa, useGrouping: "min2", hace que el inglés se comporte como el español e imprima 1234.

Moneda y porcentaje: dónde va el símbolo

Para un importe de 1234,50, el inglés formatea $1,234.50: símbolo primero, sin espacio, agrupando desde el millar. Las cinco locales europeas ponen todas el símbolo al final, y todas un espacio duro antes: el francés produce el importe con espacios finos duros en las cifras, luego un espacio duro y el símbolo €; el alemán produce 1.234,50 seguido de ese espacio y el símbolo; el español, el portugués y el italiano producen 1234,50 seguido de lo mismo, sin agrupar por la regla de las cuatro cifras de arriba.

Ese espacio duro antes del símbolo es un carácter real y está ahí a propósito: impide que un salto de línea separe el importe de su unidad. También rompe los mismos analizadores ingenuos que el separador de grupos, y hace que la comparación de cadenas con un valor esperado escrito a mano falle en las pruebas sin razón visible. Compara la salida formateada con formatToParts, no por igualdad con un literal que tecleaste.

El porcentaje tiene su propia trampa, y no va de separadores. style: "percent" multiplica por 100 antes de formatear. Pasar 12,34 porque ya convertiste la razón da 1.234 %: un número cien veces mayor, impreso sin protestar. Pasa la razón cruda, 0,1234, y obtienes 12,34 %. El espaciado también difiere: el inglés escribe 12.3% sin hueco, el francés, el español y el alemán insertan un espacio duro antes del signo, y el portugués y el italiano no.

Cifras decimales, redondeo y las trampas de las opciones

El valor por defecto para un número simple es un máximo de tres decimales, así que formatear 1,23456 da 1,235 y el resto desaparece. Para una moneda el valor por defecto viene de la propia moneda: dos decimales para el dólar y el euro, cero para el yen, tres para el dinar tunecino. Suele ser lo que quieres, y conviene saber que ocurre en vez de suponer dos en todas partes.

Las dos opciones que generan tiques de soporte son minimumFractionDigits y maximumFractionDigits. Poner el mínimo por encima del máximo lanza un RangeError en vez de recortar, lo cual al menos es ruidoso. Poner solo el mínimo eleva en silencio el máximo para que cuadre: minimumFractionDigits: 4 sobre 1,23456789 imprime 1,2346 y no los dos decimales que quizá esperabas desde otra parte del código.

El redondeo tiene un valor por defecto que conviene conocer. Intl redondea la mitad alejándose de cero: 2,5 pasa a 3 y -0,5 pasa a -1 con cero decimales; pasa roundingMode: "halfEven" y 2,5 pasa a 2, que es lo que suelen querer la contabilidad y la estadística. Y Intl no es toFixed: redondear 1,005 a dos decimales da 1,01 con Intl y 1,00 con toFixed, y redondear 2,675 da 2,68 con Intl y 2,67 con toFixed. La diferencia es que toFixed redondea el doble binario, cuyo valor queda ligerísimamente por debajo del decimal que escribiste, mientras Intl redondea el decimal que querías.

La notación compacta, que es una traducción y no una abreviatura

Poner notation: "compact" convierte 1234567 en 1.2M en inglés. Las otras cinco locales no usan todas M: el francés da el importe con una M, el español y el portugués también, el alemán da Mio. y el italiano Mln. En forma larga las diferencias son más claras: 1.2 million, 1,2 million, 1,2 millones, 1,2 milhões, 1,2 Millionen, 1,2 milioni.

Los millares son aún más dispares. El inglés da 1.5K para 1500, el francés 1,5 k con k minúscula, el español y el portugués 1,5 mil, el italiano 1,5K, y el alemán da 1500, sin cambio, porque CLDR no tiene forma compacta corta para los millares en alemán. Si tu panel supone que todas las locales acortan igual, la columna alemana será más ancha que las demás y no hay nada que configurar al respecto.

Por qué parseFloat no puede deshacer nada de esto

toLocaleString es una función de un solo sentido. Formateamos 1234567,891 en cada una de las seis locales y devolvimos el resultado tal cual a Number.parseFloat. El inglés devolvió 1. El francés devolvió 1. El portugués devolvió 1. El español, el alemán y el italiano devolvieron 1,234. Ninguno devolvió el valor original, y los tres últimos son los peligrosos, porque 1,234 es un número perfectamente plausible que ninguna validación rechazará.

Number() al menos es honesto: devuelve NaN para las seis, porque ninguna es un literal numérico válido. Eso hace de Number() la mejor guarda si solo compruebas si una cadena es un número de máquina pelado, y lo hace inútil como analizador de cualquier cosa que haya leído una persona.

El orden de la eliminación importa más de lo que se cree. Toma la cadena alemana 1.234.567,891. Quita primero los puntos y luego convierte la coma en punto: obtienes 1234567.891, correcto. Convierte primero la coma en punto y luego quita los puntos: obtienes 1234567891, mil veces mayor y aun así un entero plausible. Ambas son funciones de dos líneas y solo una es correcta.

Escribimos la versión guiada por la locale y la probamos contra las seis: leer los caracteres de grupo y decimal desde formatToParts, borrar cada aparición del carácter de grupo, sustituir el carácter decimal por un punto, tirar todo lo que quede que no sea dígito ni signo, y convertir. Reprodujo exactamente el valor original en las seis, y también analizó las seis cadenas monetarias, símbolos y espacios duros incluidos, sin ningún código específico de locale.

1234567,891
Salida de Intl.NumberFormat para 1234567,891 y para un importe de 1234,50 — Node 26.3.0, ICU 78.3
Locale1234567,891Separador de gruposSeparador decimalImporte, 1234,50
en-US1,234,567.891ComaPunto$1,234.50 (símbolo primero)
fr-FR1 234 567,891U+202F espacio fino duroComa1 234,50 € (símbolo al final)
es-ES1.234.567,891PuntoComa1234,50 € (sin agrupar por debajo de 10.000)
pt-PT1 234 567,891U+00A0 espacio duroComa1234,50 € (sin agrupar por debajo de 10.000)
de-DE1.234.567,891PuntoComa1.234,50 € (símbolo al final)
it-IT1.234.567,891PuntoComa1234,50 € (sin agrupar por debajo de 10.000)
Formateador de númerosAñade separadores de miles y fija los decimales de los números de tu texto.Probar la herramienta

Preguntas frecuentes

¿Qué separador usa realmente el francés para los millares?
U+202F NARROW NO-BREAK SPACE, en los datos CLDR vigentes al escribir esto: lo leímos directamente de formatToParts en Node 26 con ICU 78. No es la barra espaciadora ni U+00A0, que es lo que usa el portugués europeo. Versiones antiguas de CLDR usaban U+00A0 también para el francés, así que cualquier código que fije uno de ellos se romperá al actualizar el motor. Lee el separador en tiempo de ejecución y la pregunta deja de importar.
¿Por qué 1234 se imprime sin separador en español e italiano?
Porque CLDR fija en dos el mínimo de cifras agrupadas de esas locales, lo que significa que la agrupación solo empieza cuando hay al menos dos cifras antes del primer separador. Así 1234 se escribe pelado y 10.000 se agrupa. Es deliberado: en esas lenguas los números de cuatro cifras suelen ser años o referencias. Pasa useGrouping: "always" si necesitas el separador de todos modos, por ejemplo para mantener alineada una columna de cifras.
¿Puedo usar toLocaleString y parseFloat como pareja?
No, y el fallo es silencioso. Formateamos 1234567,891 en seis locales y ejecutamos parseFloat sobre cada resultado: tres devolvieron 1 y tres devolvieron 1,234. Ninguno devolvió el original. Number() al menos devuelve NaN para las seis, así que falla ruidosamente. Formatear es para mostrar, y analizar necesita su propia ruta de código guiada por los separadores de la locale.
¿Debo guardar números formateados o crudos?
Crudos, siempre, y formatea en el último momento posible. Un valor guardado 1234567.891 no es ambiguo y la aritmética funciona sobre él. Una cadena guardada 1.234.567,891 arrastra un idioma que luego hay que recordar, no se ordena numéricamente y convierte cada cálculo en un análisis. La forma formateada pertenece a la capa de vista, generada por petición desde la locale del lector.
¿Por qué toFixed(2) discrepa de Intl en 1,005?
Porque redondean cosas distintas. El valor en doble precisión más cercano a 1,005 es ligerísimamente menor que 1,005, y toFixed redondea ese valor binario, así que produce 1,00. Intl redondea el número decimal que pediste y produce 1,01. La misma división aparece en 2,675, donde toFixed da 2,67 e Intl da 2,68. Si hay dinero de por medio, usa Intl o un tipo decimal exacto, y nunca mezcles ambos en un mismo informe.
¿Basta un código de idioma pelado para Intl?
No siempre, y el portugués es el ejemplo más claro. Pedir «pt» a Intl da convenciones brasileñas —punto para los millares, 1.234.567,891— porque ahí está la mayoría de lusohablantes, mientras que «pt-PT» da la forma europea con espacio duro. El inglés se comporta igual, a la inversa, en convenciones de fecha y medida. Si tu público para un idioma es un país concreto, nombra el país en la etiqueta.

Artículos que podrían interesarte

Todas las guías
ExplicaciónContar palabras es ambiguo, y cada herramienta responde distintoUn recuento de palabras es una definición, no una medida. Contamos el mismo párrafo de cuatro formas y obtuvimos 25, 28, 33 y 38; luego contamos 50 000 caracteres de prosa corriente y obtuvimos acuerdo dentro del 4,5 %. La diferencia se debe por completo a compuestos, cifras y URL.ExplicaciónLos emoji son más difíciles de lo que parecen: por qué «basta con quitarlos» no tiene respuesta en una líneaUn emoji visible puede valer un punto de código o catorce unidades UTF-16. Lanzamos tres expresiones regulares populares sobre una frase real y cada una falló de otra forma; una borró los dígitos. Aquí está el porqué, qué propiedad Unicode responde a qué pregunta, y la regla de grupos de grafemas que sí funciona.TutorialFiltrar líneas por un patrón sin línea de comandosEsto es grep para quien no usa grep, con una diferencia importante: la búsqueda es una subcadena literal, así que una expresión regular de verdad devuelve un cuadro vacío y ningún error. Cada afirmación se comprobó ejecutando la herramienta.ExplicaciónDónde puede cortarse una línea: el algoritmo Unicode detrás de cada párrafo ajustado«Cortar en los espacios» falla en la mayoría de los sistemas de escritura. UAX #14 da a cada carácter una clase de corte de línea; buscamos las nuestras en Unicode 17.0.0 y ejecutamos una implementación conforme sobre espacios duros, guiones blandos, espacios de ancho cero, URL, japonés y tailandés.GuíaConvertir entre formatos de lista sin perder datos: las reglas de comillas que nadie leePasar de una lista con saltos de línea a una lista con comas es trivial hasta que un elemento contiene una coma. Las reglas de comillas de la RFC 4180, por qué un campo CSV puede contener un salto de línea, por qué las hojas de cálculo europeas usan el punto y coma, y qué le hace un elemento vacío a la ida y vuelta — cada caso ejecutado e impreso.GuíaQuitar el Markdown: lo que el texto plano pierde, y en qué se equivoca una regexUn enlace se convierte en texto con su destino borrado, una lista anidada pierde su jerarquía, una tabla se vuelve una fila de palabras. Luego la mitad técnica: el markdown no tiene una especificación única, y un limpiador a base de regex destroza un nombre de archivo, un signo de multiplicación y el interior de un bloque de código — todo confrontado con un analizador de verdad.

Herramientas relacionadas

Fuentes

¿Has detectado un error en este artículo?