Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

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.

Rodolfo Mori10 min de leitura

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:

js
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));
R$ 1.234,50 R$ 0,00 -R$ 89,90 R$ 1.234.567,89

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:

js
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' }));
R$ 1.234,50 1.234,5 $1,234.50 R$ 0,30

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:

js
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);
false 82 36 160 56 57 44 57 48 160 32 "R$ 89,90" 1

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:

js
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');
node:internal/modules/run_main:107 triggerUncaughtException( ^

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:

js
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));
true true [ { type: 'currency', value: 'R$' }, { type: 'literal', value: ' ' }, { type: 'integer', value: '89' }, { type: 'decimal', value: ',' }, { type: 'fraction', value: '90' } ]

\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:

js
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'));
NaN 1.234 1 1234.56 89 NaN

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:

js
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));
}
"1.234,56" -> 1234.56 "R$ 89,90" -> 89.9 "1234.56" -> 123456 "" -> 0 "grátis" -> NaN

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:

js
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));
}
"R$ 1.234,56" -> 1234.56 "1.234,56" -> 1234.56 "89,90" -> 89.9 "1234.56" -> 1234.56 "1234" -> 1234 "-R$ 89,90" -> -89.9 "" -> null "grátis" -> null 19.9 -> 19.9

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.
  • null para o que não é número. 0 mente e NaN contamina 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:

js
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));
R$ 1.235 R$ 0,0725 R$ 1,2 mi

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:

js
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));
"1.234,50" BRL 1.234,50 1.234,50 Reais brasileiros 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:

js
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);
NaN R$ NaN file:///private/tmp/loja/pedido-nan.mjs:9 const centavos = BigInt(Math.round(totalEmReais * 100)); ^

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:

js
const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency' });

console.log(formatador.format(89.9));
file:///private/tmp/loja/moeda-sem-currency.mjs:1 const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency' }); ^

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:

js
const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'R$' });

console.log(formatador.format(89.9));
file:///private/tmp/loja/moeda-cifrao.mjs:1 const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'R$' }); ^

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:

js
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');
formatador dentro do laço: 302.4 ms formatador reaproveitado: 5.4 ms diferença: 55.6x

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:

js
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));
1x Teclado mecânico R$ 289,90 2x Mouse sem fio R$ 299,80 1x Mousepad grande R$ 49,90 Subtotal: R$ 639,60 Frete: Grátis Cupom BEMVINDO10: -R$ 63,96 Total: R$ 575,64

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 NumberNaN 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.

Ver todos os vídeos do canal
  • moeda
  • intl
  • formatacao
  • real
  • locale

Perguntas frequentes

Por que meu teste falha se a string na tela está visualmente igual?
Porque o separador que o Intl coloca entre "R$" e o número não é o espaço comum (código 32) e sim o espaço não-quebrável U+00A0 (código 160). Os dois pixels são idênticos e as duas strings são diferentes. Compare com formatToParts, ou normalize com replace(/\s/g, ' ') antes de comparar.
Preciso instalar alguma biblioteca para formatar real?
Não. Intl.NumberFormat faz parte da linguagem e o Node vem com os dados de pt-BR embutidos desde a versão 13. Biblioteca de moeda só se paga quando você precisa de aritmética decimal exata, não de formatação.
Por que parseFloat("1.234,56") devolve 1.234?
Porque parseFloat lê da esquerda para a direita usando a notação do JavaScript, onde o ponto é decimal e a vírgula não significa nada. Ele aceita "1.234", para na vírgula e descarta o resto sem avisar. É pior que NaN: o pedido de mil e duzentos reais vira um pedido de um real e vinte e três centavos.
Devo guardar o valor formatado no banco?
Nunca. No banco vai o inteiro em centavos; a formatação acontece na borda, na hora de desenhar a tela. Guardar "R$ 1.234,56" te obriga a desformatar em todo lugar que lê o campo, e cada desformatação é uma chance nova de aparecer NaN.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. MDN — Intl.NumberFormat — developer.mozilla.org
  2. MDN — Number.prototype.toLocaleString — developer.mozilla.org
  3. MDN — parseFloat — developer.mozilla.org

Continue por aqui