Formatar moeda em JavaScript: real, Intl e centavos
Intl.NumberFormat em pt-BR, o espaço não-quebrável que derruba o seu teste e a função que traz "1.234,56" de volta para número — executado no Node 24.
Formatar dinheiro em JavaScript é uma linha: Intl.NumberFormat com o locale
pt-BR e a moeda BRL. O trabalho de verdade começa depois — quando o valor
formatado precisa voltar a ser número, quando o teste automatizado diz que
'R$ 89,90' é diferente de 'R$ 89,90', e quando o cliente vê R$ NaN no
resumo do pedido.
Esta lição é a continuação direta de números em JavaScript: lá você guardou tudo em centavos inteiros; aqui você leva esses centavos para a tela e traz de volta o que o usuário digitou. O domínio continua sendo a loja: preço, cupom, frete, resumo do pedido.
O nome técnico é formatação localizada: o número continua sendo número no cálculo, mas ganha símbolo, separadores e espaços para ser exibido. Misturar o valor de cálculo com a string de apresentação é a origem dos bugs desta lição.
Etiqueta na vitrine, número no caixa
Uma etiqueta pode mostrar “R$ 1.234,56”, mas o sistema do caixa precisa guardar
uma quantidade calculável, como 123456 centavos. A etiqueta é apresentação; o
valor interno é dado. Intl.NumberFormat faz a passagem de um para o outro sem
alterar o número original.
Antes do primeiro exemplo, anote o tipo esperado antes e depois da formatação e
confira com typeof quando executar. Depois conte os caracteres da string. Esse
segundo resultado revela espaços e sinais que o olho ignora, mas um teste
compara exatamente.
Não precisa de biblioteca. Intl faz parte do JavaScript e o Node carrega os
dados de pt-BR por padrão:
const preco = 1234.5;
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
console.log(brl.format(preco));
console.log(brl.format(0));
console.log(brl.format(-89.9));
console.log(brl.format(1234567.891));Quatro convenções brasileiras aplicadas de graça: ponto separando milhar,
vírgula separando decimal, duas casas sempre (o 1234.5 virou 1.234,50) e o
sinal negativo antes do símbolo. Escrever isso na mão com replace e
toFixed é o caminho para descobrir, seis meses depois, que faltou tratar o
milhão.
Repare também na última linha: 1234567.891 saiu como R$ 1.234.567,89. O
Intl arredonda para duas casas por conta própria.
toLocaleString, o atalho de uma linha
Todo número tem o método toLocaleString, que aceita exatamente as mesmas
opções e devolve a mesma string:
const preco = 1234.5;
console.log(preco.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }));
console.log(preco.toLocaleString('pt-BR'));
console.log(preco.toLocaleString('en-US', { style: 'currency', currency: 'USD' }));
console.log((0.1 + 0.2).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }));A quarta linha merece atenção: 0.1 + 0.2 é 0.30000000000000004, e mesmo
assim a tela mostra R$ 0,30. A formatação esconde o erro de ponto
flutuante em vez de resolvê-lo — o valor continua torto por dentro, e vai
continuar torto na próxima soma.
Use toLocaleString quando é uma chamada isolada. Para uma lista, crie o
formatador uma vez — a diferença é grande, e eu meço isso mais para baixo.
O espaço que não é espaço
Aqui está a pegadinha que custa uma tarde de trabalho a quem escreve teste. O
que o Intl coloca entre R$ e o número não é o espaço do seu teclado:
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
const formatado = brl.format(89.9);
console.log(formatado === 'R$ 89,90');
console.log([...formatado].map((c) => c.charCodeAt(0)).join(' '));
console.log(formatado.charCodeAt(2), 'R$ 89,90'.charCodeAt(2));
console.log(JSON.stringify(formatado));
console.log(formatado.split(' ').length);Linha por linha: as duas strings são diferentes; o terceiro caractere do
resultado tem código 160, e o do literal que eu digitei tem código 32;
o JSON.stringify imprime as duas iguais, porque o caractere 160 é imprimível;
e split(' ') devolve um pedaço só, provando que não existe espaço comum
ali dentro.
O 160 é o U+00A0, o espaço não-quebrável — o mesmo do HTML. Ele
existe por um motivo tipográfico legítimo: impedir que o navegador quebre a
linha entre o R$ e o valor. E ele destrói qualquer comparação ingênua.
O sintoma clássico é o teste que falha mostrando dois valores idênticos:
import assert from 'node:assert/strict';
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
assert.equal(brl.format(89.9), 'R$ 89,90');AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
-
actual - expected
-
‘R$ 89,90’
-
‘R$ 89,90’ ^
at file:///private/tmp/loja/moeda-teste.mjs:5:8 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) Node.js v24.16.0
+ 'R$ 89,90' contra - 'R$ 89,90', e o cursor ^ apontando para o meio da
string. O Node está certo e você também: a diferença está no byte, não no
desenho.
Três saídas, em ordem de preferência:
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
const formatado = brl.format(89.9);
console.log(formatado.replace(/\s/g, ' ') === 'R$ 89,90');
console.log(formatado.normalize('NFKC') === 'R$ 89,90');
console.log(brl.formatToParts(89.9));\s na expressão regular casa com o U+00A0 também, então
replace(/\s/g, ' ') normaliza qualquer separador exótico para o espaço comum —
é a correção de uma linha para o seu teste. normalize('NFKC') faz o mesmo por
um caminho oficial de compatibilidade Unicode.
A terceira é a melhor quando você precisa montar a interface por conta própria:
formatToParts devolve a string já fatiada por papel. Com ela você põe o
R$ num <span> menor sem depender de split, e o literal (que é justamente
o U+00A0) fica isolado, seu, para descartar ou manter. Se separar texto ainda é
um assunto novo, os métodos de string
cobrem o replace e o split com calma.
A volta: de "1.234,56" para número
O Intl cuida da formatação. O caminho de volta não tem um recurso equivalente,
e é onde o projeto quebra:
const gravado = '1.234,56';
console.log(Number(gravado));
console.log(parseFloat(gravado));
console.log(parseInt(gravado, 10));
console.log(Number('1234.56'));
console.log(parseFloat('89,90'));
console.log(Number('89,90'));Number('1.234,56') é NaN — ruim, mas honesto. O perigoso é a segunda linha:
parseFloat devolveu 1.234. Ele lê da esquerda para a direita usando a
notação da linguagem, onde o ponto é o separador decimal e a vírgula não
significa nada. Aceita 1.234, para na vírgula e joga fora o resto sem uma
palavra. Um pedido de mil duzentos e trinta e quatro reais virou um pedido de um
real e vinte e três centavos, e nenhuma exceção foi lançada.
A quinta linha repete a lição: parseFloat('89,90') é 89. Os noventa centavos
sumiram. A diferença completa entre Number, parseInt e parseFloat está em
converter string para número;
aqui basta a regra: nenhum dos três entende vírgula decimal.
Uma função de desformatar que aguenta o mundo real
A ingênua troca ponto por nada e vírgula por ponto. Ela funciona — até receber um valor que já estava certo:
function desformatarBRL(texto) {
const limpo = String(texto)
.replace(/[R$\s]/g, '')
.replace(/\./g, '')
.replace(',', '.');
return Number(limpo);
}
for (const entrada of ['1.234,56', 'R$ 89,90', '1234.56', '', 'grátis']) {
console.log(JSON.stringify(entrada), '->', desformatarBRL(entrada));
}Dois desastres nas três últimas linhas. '1234.56' — um valor em notação de
banco de dados, com ponto decimal — virou 123456, cem mil reais a mais,
porque a função apagou o ponto achando que era separador de milhar. E a string
vazia virou 0, que num campo de desconto significa “sem desconto” e num campo
de preço significa “de graça”.
Uma versão mais defensiva decide o que fazer olhando se existe vírgula e
devolve null — não 0, não NaN — quando a entrada não é um valor:
function moedaParaNumero(texto) {
if (typeof texto === 'number') return Number.isFinite(texto) ? texto : null;
const limpo = String(texto).replace(/[^\d,.-]/g, '');
if (limpo === '' || limpo === '-') return null;
const emPontoDecimal = limpo.includes(',')
? limpo.replace(/\./g, '').replace(',', '.')
: limpo;
const numero = Number(emPontoDecimal);
return Number.isNaN(numero) ? null : numero;
}
for (const entrada of ['R$ 1.234,56', '1.234,56', '89,90', '1234.56', '1234', '-R$ 89,90', '', 'grátis', 19.9]) {
console.log(JSON.stringify(entrada).padEnd(14), '->', moedaParaNumero(entrada));
}Três decisões explícitas que valem para qualquer função sua de entrada:
- Ponto só é separador de milhar quando existe uma vírgula depois dele. Sem vírgula na string, o ponto é decimal e fica quieto.
nullpara o que não é número.0mente eNaNcontamina toda conta seguinte em silêncio.- Número entra e sai inteiro. A função é idempotente, então chamar duas vezes por engano não estraga o valor.
Quantas casas decimais você quer
minimumFractionDigits e maximumFractionDigits controlam o piso e o teto:
const preco = 1234.5;
const semCentavos = new Intl.NumberFormat('pt-BR', {
style: 'currency', currency: 'BRL', minimumFractionDigits: 0, maximumFractionDigits: 0,
});
const unitario = new Intl.NumberFormat('pt-BR', {
style: 'currency', currency: 'BRL', minimumFractionDigits: 4,
});
const compacto = new Intl.NumberFormat('pt-BR', {
style: 'currency', currency: 'BRL', notation: 'compact',
});
console.log(semCentavos.format(preco));
console.log(unitario.format(0.0725));
console.log(compacto.format(1234567.89));minimumFractionDigits: 0 é o formato de etiqueta de vitrine, e repare que ele
arredonda: 1234.5 virou R$ 1.235. minimumFractionDigits: 4 é o preço
unitário de quem vende a granel, onde o quarto decimal é dinheiro de verdade
numa nota de mil unidades. E notation: 'compact' é o painel de faturamento,
onde ninguém quer contar dígitos.
O padrão, quando você não diz nada, vem da própria moeda: BRL tem duas casas,
o iene japonês tem zero. É mais um motivo para deixar o Intl decidir.
Sem símbolo, para dentro do campo de digitação
Num input de preço, o R$ normalmente já está desenhado ao lado. O que você
quer ali é o número no formato brasileiro e nada mais:
const semSimbolo = new Intl.NumberFormat('pt-BR', {
style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
const codigo = new Intl.NumberFormat('pt-BR', {
style: 'currency', currency: 'BRL', currencyDisplay: 'code',
});
const extenso = new Intl.NumberFormat('pt-BR', {
style: 'currency', currency: 'BRL', currencyDisplay: 'name',
});
console.log(JSON.stringify(semSimbolo.format(1234.5)));
console.log(codigo.format(1234.5));
console.log(extenso.format(1234.5));
console.log(new Intl.NumberFormat('pt-BR', { style: 'percent' }).format(0.15));Note o JSON.stringify da primeira linha: "1.234,50", sem espaço nenhum. Com
style: 'decimal' não existe símbolo, logo não existe o separador U+00A0 — o
valor entra no input sem sujeira invisível. É a máscara de dinheiro sem
biblioteca de máscara.
currencyDisplay: 'code' é para relatório e exportação contábil; 'name'
resolve o texto de comprovante e de leitor de tela.
O erro que aparece três telas depois
Alguém, em algum ponto do passado, gravou o total do pedido já formatado. O código que lê esse campo não desconfia de nada:
const pedido = { id: 8412, total: '1.234,56' };
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
const totalEmReais = Number(pedido.total);
console.log(totalEmReais);
console.log(brl.format(totalEmReais));
const centavos = BigInt(Math.round(totalEmReais * 100));
console.log(centavos);RangeError: The number NaN cannot be converted to a BigInt because it is not an integer at BigInt (<anonymous>) at file:///private/tmp/loja/pedido-nan.mjs:9:18 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) Node.js v24.16.0
Leia a sequência de trás para frente, porque é assim que ela chega até você em
produção. O que explodiu foi a linha 9, na conversão para BigInt exigida pelo
gateway de pagamento — a três arquivos de distância do erro real. Antes disso, a
linha 6 já tinha imprimido R$ NaN na tela do cliente sem lançar exceção
nenhuma: Intl.NumberFormat formata NaN como qualquer outro número e segue a
vida. E a causa verdadeira é a linha 4, o Number('1.234,56').
Com moedaParaNumero no lugar do Number cru, a linha 4 devolve 1234.56, o
resto funciona, e o dia em que o campo vier realmente vazio você recebe null —
um valor que dá para testar com if, em vez de um NaN que se espalha.
Dois erros de configuração do Intl
O primeiro é pedir moeda sem dizer qual:
const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency' });
console.log(formatador.format(89.9));TypeError: Currency code is required with currency style. at new NumberFormat (<anonymous>) at file:///private/tmp/loja/moeda-sem-currency.mjs:1:20 Node.js v24.16.0
O segundo é passar o símbolo no lugar do código:
const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'R$' });
console.log(formatador.format(89.9));RangeError: Invalid currency code : R$ at new NumberFormat (<anonymous>) at file:///private/tmp/loja/moeda-cifrao.mjs:1:20 Node.js v24.16.0
currency espera o código ISO 4217 de três letras — BRL, USD, EUR — e não
o símbolo. Os dois erros estouram na construção do formatador, não na
chamada de format. Isso é uma boa notícia: se o formatador nasce no topo do
módulo, o erro aparece na hora de subir a aplicação, e não no meio de um
checkout.
Crie o formatador uma vez, use vinte mil
Construir um Intl.NumberFormat é caro: ele carrega e resolve as regras do
locale. Formatar com um já construído é barato. A diferença numa listagem de
produtos é essa:
const valores = Array.from({ length: 20000 }, (_, i) => i * 1.37);
let t = performance.now();
valores.map((v) =>
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v));
const tempoDentro = performance.now() - t;
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
t = performance.now();
valores.map((v) => brl.format(v));
const tempoFora = performance.now() - t;
console.log('formatador dentro do laço:', tempoDentro.toFixed(1), 'ms');
console.log('formatador reaproveitado: ', tempoFora.toFixed(1), 'ms');
console.log('diferença:', (tempoDentro / tempoFora).toFixed(1) + 'x');Cinquenta e cinco vezes, na minha máquina, no Node 24.16.0. Trezentos milissegundos numa listagem de vinte mil linhas é a diferença entre uma tabela que abre e uma tabela que trava. A correção é mover uma linha para fora do laço: declare o formatador no módulo e exporte a função pronta.
O carrinho inteiro, do centavo à tela
Juntando tudo — valores em centavos inteiros, formatação só na saída:
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
const emReais = (centavos) => brl.format(centavos / 100);
const carrinho = [
{ nome: 'Teclado mecânico', centavos: 28990, qtd: 1 },
{ nome: 'Mouse sem fio', centavos: 14990, qtd: 2 },
{ nome: 'Mousepad grande', centavos: 4990, qtd: 1 },
];
const subtotal = carrinho.reduce((s, i) => s + i.centavos * i.qtd, 0);
const frete = subtotal >= 29900 ? 0 : 2490;
const desconto = Math.round(subtotal * 0.1);
const total = subtotal + frete - desconto;
for (const item of carrinho) {
console.log(`${item.qtd}x ${item.nome.padEnd(18)} ${emReais(item.centavos * item.qtd)}`);
}
console.log('Subtotal:'.padEnd(22), emReais(subtotal));
console.log('Frete:'.padEnd(22), frete === 0 ? 'Grátis' : emReais(frete));
console.log('Cupom BEMVINDO10:'.padEnd(22), '-' + emReais(desconto));
console.log('Total:'.padEnd(22), emReais(total));Nenhuma conta foi feita em reais. subtotal, frete, desconto e total são
inteiros, o Math.round do desconto arredonda centavo e não fração de centavo,
e a divisão por 100 acontece uma vez só, dentro de emReais. É esse
desenho — inteiro por dentro, string na borda — que faz o total bater com o do
financeiro.
Regras práticas
| situação | o que usar | por quê |
|---|---|---|
| exibir um preço | Intl.NumberFormat('pt-BR', { currency: 'BRL' }) |
acerta milhar, decimal e sinal sem replace |
| formatar uma lista | um formatador criado fora do laço | 55x mais rápido em 20 mil linhas |
| comparar em teste | formatToParts ou replace(/\s/g, ' ') |
o separador é U+00A0, não espaço |
| ler o que o usuário digitou | função própria, com null no erro |
Number dá NaN e parseFloat mente |
preencher um input |
style: 'decimal' |
sai sem símbolo e sem espaço invisível |
| guardar no banco | inteiro em centavos | string formatada precisa ser desformatada em todo lugar |
O próximo passo da trilha de JavaScript é sortear e
embaralhar — cupom da roleta, ordem do sorteio, código de voucher. E se o NaN
desta lição chegou a virar um undefined no seu componente, o diagnóstico está
em TypeError: Cannot read properties of undefined.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Por que meu teste falha se a string na tela está visualmente igual?
Preciso instalar alguma biblioteca para formatar real?
Por que parseFloat("1.234,56") devolve 1.234?
Devo guardar o valor formatado no banco?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Intl.NumberFormat — developer.mozilla.org
- MDN — Number.prototype.toLocaleString — developer.mozilla.org
- MDN — parseFloat — developer.mozilla.org



