Formatar números para seis línguas: separadores, moeda e o regresso ao valor
Publicado a 03/10/2025 · 14 min de leitura · Ferramentas de texto e idioma
Daniel Okonkwo — Programador front-end e redator de Tecnologia na OneKitly
Desempenho web · Formatos de ficheiro
Verificado a partir de 5 fontes
A mesma quantidade escreve-se 1 234 567,891 em português europeu, 1.234.567,891 em espanhol, alemão e italiano, e 1,234,567.891 em inglês — e os espaços da versão portuguesa não são espaços que se possam escrever. Ao correr Intl.NumberFormat em Node 26 com ICU 78 aparece U+00A0 NO-BREAK SPACE como separador de grupos do português europeu e U+202F NARROW NO-BREAK SPACE como o do francês, ambos invisíveis e ambos fatais para um analisador que espera um espaço normal ou uma vírgula. Três das seis localizações recusam-se ainda a agrupar números de quatro algarismos: o espanhol, o português europeu e o italiano escrevem 1234 sem separador mas 10 000 com ele, porque o CLDR fixa em dois o seu mínimo de algarismos agrupados. A disposição monetária divide-se do mesmo modo: o inglês põe o símbolo antes dos algarismos sem nada pelo meio, enquanto as outras cinco o põem depois do número com um espaço inquebrável. Nada disto se desfaz com Number.parseFloat, que devolve 1 para a cadeia inglesa e um plausível 1,234 para a alemã. A única análise segura pergunta ao Intl.NumberFormat.formatToParts que caracteres essa localização usa realmente e depois retira o separador de grupos antes de converter o decimal — por essa ordem, porque ao contrário multiplica o valor por mil.
1 234,56 e 1,234.56 são o mesmo número, e confundi-los muda o valor que um leitor lê. Corremos Intl.NumberFormat para as seis localizações do site e imprimimos cada separador — incluindo o invisível que o francês usa — e depois medimos porque parseFloat não consegue desfazer nada disso.
O mesmo número, seis grafias
Pegámos num valor, 1234567.891, e formatámo-lo com Intl.NumberFormat nas seis localizações em que este site publica. O inglês dá 1,234,567.891. O espanhol, o alemão e o italiano dão todos 1.234.567,891 — vírgula e ponto trocaram de papéis, pelo que quem aplique hábitos ingleses lê um número mil vezes menor sem dar por isso. O francês dá 1 234 567,891 e o português europeu também, mas com um carácter invisível diferente a fazer o espaçamento.
Não é um pormenor de apresentação. Uma coluna de folha de cálculo colada de uma convenção para um sistema que espera outra muda em silêncio todos os seus valores, e a falha é invisível porque ambas as grafias são números legais. A regra a interiorizar: um número formatado é um texto acerca de um valor, numa língua concreta, e a língua tem de viajar com ele.
Mais duas convenções merecem ser conhecidas porque aparecem em dados europeus. O alemão da Suíça usa apóstrofo: de-CH formata o mesmo valor como 1'234'567.891, com ponto para o decimal. E uma etiqueta de língua nua não equivale a uma de língua e região: pedir «pt» ao Intl produz convenções brasileiras, 1.234.567,891, enquanto «pt-PT» produz a forma europeia com espaços. Se o seu público lusófono é europeu, a etiqueta nua está silenciosamente errada.
O separador que não se vê
O separador de grupos francês nos dados CLDR atuais não é a barra de espaço. Pedir ao Intl.NumberFormat as partes de um número francês devolve U+202F NARROW NO-BREAK SPACE. O português europeu usa U+00A0 NO-BREAK SPACE, um carácter diferente de largura diferente. Ambos parecem exatamente um espaço em qualquer editor, terminal e navegador, e nenhum deles o é.
As consequências são inteiramente práticas. Uma regra de validação escrita /^[\d ,]+$/ rejeita um número francês corretamente formatado, porque o carácter que ele contém não é o espaço da classe. Um localizar-e-substituir que retira espaços deixa o separador intacto. Uma exportação CSV produz células que a folha de cálculo lê como texto e não como números. E copiar o número de uma página para o colar numa calculadora dá um erro que ninguém sabe explicar, porque o carácter culpado é invisível dos dois lados da colagem.
O correto é nunca adivinhar o separador. Intl.NumberFormat(locale).formatToParts(12345.6) devolve um pequeno arranjo em que uma entrada tem o tipo «group» e outra o tipo «decimal», e os seus valores são exatamente os caracteres que essa localização usa hoje. Leia-os em tempo de execução e o seu código continua a funcionar quando o CLDR mudar, o que acontece: o separador francês era um espaço inquebrável normal em dados mais antigos, antes de o estreito o substituir.
Três localizações não agrupam os números de quatro algarismos
Formate 1234 nas seis localizações e os resultados não batem certo. O inglês dá 1,234, o francês 1 234 e o alemão 1.234 — mas o espanhol, o português europeu e o italiano dão todos 1234, sem separador nenhum. Suba para 10000 e todos agrupam: 10,000, 10 000, 10.000 e 10.000 respetivamente. A mudança ocorre entre os quatro e os cinco algarismos.
A regra por trás é uma definição do CLDR chamada mínimo de algarismos agrupados, que diz quantos algarismos têm de ficar à esquerda do primeiro separador para que agrupar valha a pena. O espanhol, o português europeu e o italiano fixam-na em dois; o inglês, o francês e o alemão em um. Existe porque um número de quatro algarismos nessas línguas é muitas vezes um ano ou uma referência e lê-se melhor sem corte. Se ainda assim precisa do agrupamento — uma tabela de montantes cujas colunas têm de alinhar — passe useGrouping: "always" e o espanhol devolve 1.234. A opção inversa, useGrouping: "min2", faz o inglês comportar-se como o espanhol e imprimir 1234.
Moeda e percentagem: onde fica o símbolo
Para um montante de 1234,50, o inglês formata $1,234.50 — símbolo primeiro, sem espaço, a agrupar desde o milhar. As cinco localizações europeias põem todas o símbolo no fim, e todas um espaço inquebrável antes dele: o francês produz o montante com espaços finos inquebráveis nos algarismos, depois um espaço inquebrável e o símbolo €; o alemão produz 1.234,50 seguido desse espaço e do símbolo; o espanhol, o português e o italiano produzem 1234,50 seguido do mesmo, sem agrupar por causa da regra dos quatro algarismos acima.
Esse espaço inquebrável antes do símbolo é um carácter real e está ali de propósito: impede que uma quebra de linha separe o montante da sua unidade. Também parte os mesmos analisadores ingénuos que o separador de grupos, e faz a comparação de cadeias com um valor esperado escrito à mão falhar nos testes sem razão visível. Compare a saída formatada com formatToParts, não por igualdade com um literal que escreveu.
A percentagem tem a sua própria armadilha, e não é sobre separadores. style: "percent" multiplica por 100 antes de formatar. Passar 12,34 porque já converteu a razão dá 1234% — um número cem vezes maior, impresso sem se queixar. Passe a razão crua, 0,1234, e obtém 12,34%. O espaçamento também difere: o inglês escreve 12.3% sem intervalo, o francês, o espanhol e o alemão inserem um espaço inquebrável antes do sinal, e o português e o italiano não.
Casas decimais, arredondamento e as armadilhas das opções
O valor por omissão para um número simples é um máximo de três casas decimais, pelo que formatar 1,23456 dá 1,235 e o resto desaparece. Para uma moeda o valor por omissão vem da própria moeda: duas casas para o dólar e o euro, zero para o iene, três para o dinar tunisino. É normalmente o que se quer, e vale a pena saber que acontece em vez de supor duas em toda a parte.
As duas opções que geram pedidos de apoio são minimumFractionDigits e maximumFractionDigits. Pôr o mínimo acima do máximo lança um RangeError em vez de cortar, o que pelo menos é ruidoso. Pôr só o mínimo eleva em silêncio o máximo para condizer: minimumFractionDigits: 4 sobre 1,23456789 imprime 1,2346 e não as duas casas que talvez esperasse de outro sítio do código.
O arredondamento tem um valor por omissão que convém conhecer. O Intl arredonda a metade afastando-se de zero: 2,5 passa a 3 e -0,5 passa a -1 com zero casas; passe roundingMode: "halfEven" e 2,5 passa a 2, que é o que a contabilidade e a estatística normalmente querem. E o Intl não é o toFixed: arredondar 1,005 a duas casas dá 1,01 com o Intl e 1,00 com o toFixed, e arredondar 2,675 dá 2,68 com o Intl e 2,67 com o toFixed. A diferença é que o toFixed arredonda o duplo binário, cujo valor fica ligeiríssimamente abaixo do decimal que escreveu, enquanto o Intl arredonda o decimal que pretendia.
A notação compacta, que é uma tradução e não uma abreviatura
Definir notation: "compact" transforma 1234567 em 1.2M em inglês. As outras cinco localizações não usam todas M: o francês dá o montante com um M, o espanhol e o português também, o alemão dá Mio. e o italiano Mln. Na forma longa as diferenças são mais claras: 1.2 million, 1,2 million, 1,2 millones, 1,2 milhões, 1,2 Millionen, 1,2 milioni.
Os milhares são ainda mais díspares. O inglês dá 1.5K para 1500, o francês 1,5 k com k minúsculo, o espanhol e o português 1,5 mil, o italiano 1,5K — e o alemão dá 1500, sem alteração, porque o CLDR não tem forma compacta curta para os milhares em alemão. Se o seu painel assume que todas as localizações encurtam do mesmo modo, a coluna alemã será mais larga do que as outras e não há nada a configurar sobre isso.
Porque parseFloat não consegue desfazer nada disto
toLocaleString é uma função de sentido único. Formatámos 1234567,891 em cada uma das seis localizações e devolvemos o resultado tal e qual ao Number.parseFloat. O inglês devolveu 1. O francês devolveu 1. O português devolveu 1. O espanhol, o alemão e o italiano devolveram 1,234. Nenhum devolveu o valor original, e os três últimos são os perigosos, porque 1,234 é um número perfeitamente plausível que nenhuma validação rejeitará.
O Number() é pelo menos honesto: devolve NaN para as seis, porque nenhuma é um literal numérico válido. Isso faz do Number() a melhor guarda se apenas verifica se uma cadeia é um número de máquina nu, e torna-o inútil como analisador de qualquer coisa que uma pessoa tenha lido.
A ordem da remoção importa mais do que se pensa. Pegue na cadeia alemã 1.234.567,891. Retire primeiro os pontos e depois converta a vírgula em ponto: obtém 1234567.891, correto. Converta primeiro a vírgula em ponto e depois retire os pontos: obtém 1234567891, mil vezes maior e ainda assim um inteiro plausível. São ambas funções de duas linhas e só uma está certa.
Escrevemos a versão guiada pela localização e testámo-la nas seis: ler os caracteres de grupo e decimal a partir de formatToParts, apagar cada ocorrência do carácter de grupo, substituir o carácter decimal por um ponto, deitar fora tudo o que sobrar e não for algarismo nem sinal, e converter. Reproduziu exatamente o valor original nas seis, e também analisou as seis cadeias monetárias, símbolos e espaços inquebráveis incluídos, sem qualquer código específico de localização.
| Localização | 1234567,891 | Separador de grupos | Separador decimal | Montante, 1234,50 |
|---|---|---|---|---|
| en-US | 1,234,567.891 | Vírgula | Ponto | $1,234.50 (símbolo primeiro) |
| fr-FR | 1 234 567,891 | U+202F espaço fino inquebrável | Vírgula | 1 234,50 € (símbolo no fim) |
| es-ES | 1.234.567,891 | Ponto | Vírgula | 1234,50 € (sem agrupar abaixo de 10 000) |
| pt-PT | 1 234 567,891 | U+00A0 espaço inquebrável | Vírgula | 1234,50 € (sem agrupar abaixo de 10 000) |
| de-DE | 1.234.567,891 | Ponto | Vírgula | 1.234,50 € (símbolo no fim) |
| it-IT | 1.234.567,891 | Ponto | Vírgula | 1234,50 € (sem agrupar abaixo de 10 000) |
Perguntas frequentes
- Que separador usa realmente o francês para os milhares?
- U+202F NARROW NO-BREAK SPACE, nos dados CLDR em vigor à data — lemo-lo diretamente de formatToParts em Node 26 com ICU 78. Não é a barra de espaço nem U+00A0, que é o que o português europeu usa. Versões antigas do CLDR usavam U+00A0 também para o francês, portanto qualquer código que fixe um deles parte-se numa atualização do motor. Leia o separador em tempo de execução e a pergunta deixa de importar.
- Porque é que 1234 se imprime sem separador em espanhol e italiano?
- Porque o CLDR fixa em dois o mínimo de algarismos agrupados dessas localizações, o que significa que o agrupamento só começa quando há pelo menos dois algarismos antes do primeiro separador. Assim 1234 escreve-se nu e 10 000 é agrupado. É deliberado: nessas línguas os números de quatro algarismos são muitas vezes anos ou referências. Passe useGrouping: "always" se precisar do separador na mesma, por exemplo para manter uma coluna de números alinhada.
- Posso usar toLocaleString e parseFloat como par?
- Não, e a falha é silenciosa. Formatámos 1234567,891 em seis localizações e corremos parseFloat sobre cada resultado: três devolveram 1 e três devolveram 1,234. Nenhum devolveu o original. O Number() devolve pelo menos NaN para as seis, portanto falha ruidosamente. Formatar é para mostrar, e analisar precisa do seu próprio caminho de código guiado pelos separadores da localização.
- Devo guardar números formatados ou crus?
- Crus, sempre, e formate no último momento possível. Um valor guardado 1234567.891 não é ambíguo e a aritmética funciona sobre ele. Uma cadeia guardada 1.234.567,891 carrega uma língua de que depois é preciso lembrar-se, não ordena numericamente e transforma cada cálculo numa análise. A forma formatada pertence à camada de apresentação, gerada a pedido a partir da localização do leitor.
- Porque é que toFixed(2) discorda do Intl em 1,005?
- Porque arredondam coisas diferentes. O valor em dupla precisão mais próximo de 1,005 é ligeiríssimamente menor do que 1,005, e o toFixed arredonda esse valor binário, produzindo 1,00. O Intl arredonda o número decimal que pediu e produz 1,01. A mesma divisão aparece em 2,675, onde o toFixed dá 2,67 e o Intl dá 2,68. Se houver dinheiro envolvido, use o Intl ou um tipo decimal exato, e nunca misture os dois no mesmo relatório.
- Basta um código de língua nu para o Intl?
- Nem sempre, e o português é o exemplo mais claro. Pedir «pt» ao Intl dá convenções brasileiras — ponto para os milhares, 1.234.567,891 — porque é aí que está a maioria dos falantes, enquanto «pt-PT» dá a forma europeia com espaço inquebrável. O inglês comporta-se do mesmo modo, ao contrário, nas convenções de data e medida. Se o seu público para uma língua é um país concreto, nomeie o país na etiqueta.
Artigos que podem interessar-lhe
Todos os guias →Ferramentas relacionadas
Fontes
- 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()
Detetaste um erro neste artigo?