Precisão numérica e dinheiro
Esta aula explora os problemas de precisão numérica em JavaScript, especialmente ao lidar com dinheiro, e apresenta soluções práticas como trabalhar com centavos e usar toFixed com cautela.
Bem-vindo à aula sobre precisão numérica e dinheiro em JavaScript. Este é um tópico crucial para qualquer pessoa que desenvolva aplicações financeiras, e-commerces ou qualquer sistema que lide com valores monetários. Se você já tentou somar 0.1 e 0.2 no console e obteve um resultado inesperado, não está sozinho. Nesta aula, vamos entender por que isso acontece, como o JavaScript armazena números e quais estratégias podemos adotar para garantir cálculos financeiros precisos.
O JavaScript usa o padrão IEEE 754 para representar números, o que significa que alguns números decimais não podem ser representados com exatidão binária. Isso gera pequenas imprecisões que, em contextos financeiros, podem se acumular e causar erros significativos. Ao longo desta aula, você aprenderá a identificar esses problemas e a aplicar técnicas robustas para evitá-los, garantindo que suas aplicações lidem com dinheiro de forma confiável.
0.1+0.2
O exemplo clássico que demonstra a imprecisão numérica em JavaScript é a soma de 0.1 e 0.2. Se você executar 0.1 + 0.2 no console, o resultado não será o esperado 0.3, mas sim 0.30000000000000004. Isso acontece porque 0.1 e 0.2, em binário, são dízimas periódicas infinitas, e o JavaScript precisa arredondá-los para caber em 64 bits (double). Esse arredondamento gera uma pequena diferença na representação, que se manifesta no resultado final.
Esse problema não é exclusivo do JavaScript; qualquer linguagem que use ponto flutuante enfrenta o mesmo desafio. Porém, é especialmente relevante no JavaScript, pois é amplamente utilizado em aplicações web que lidam com transações financeiras. Vamos explorar por que isso ocorre em detalhes:
- O padrão IEEE 754 usa uma representação binária (base 2) para números de ponto flutuante.
- Números como 0.1 e 0.2 não têm representação binária finita, assim como 1/3 não tem representação decimal finita.
- O JavaScript usa o tipo
Number, que é um double de 64 bits, seguindo o padrão IEEE 754. - Durante operações aritméticas, essas pequenas imprecisões são propagadas, resultando em valores como 0.30000000000000004.
Para visualizar, podemos verificar a diferença:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.3 - (0.1 + 0.2)); // 5.551115123125783e-17
Essa diferença de 5.55e-17 é extremamente pequena, mas em cálculos acumulados pode se tornar significativa. Por exemplo, ao somar muitos valores, a imprecisão pode aumentar e causar discrepâncias de centavos.
Trabalhando com centavos
A solução mais comum e recomendada para lidar com dinheiro em JavaScript é trabalhar com centavos (ou a menor unidade monetária) em vez de valores decimais. Ou seja, em vez de armazenar o valor como 10.99, armazenamos como 1099 (um inteiro). Isso elimina completamente os problemas de ponto flutuante, pois números inteiros dentro de um certo intervalo são representados com precisão exata em JavaScript.
O intervalo seguro para inteiros em JavaScript é entre Number.MIN_SAFE_INTEGER e Number.MAX_SAFE_INTEGER, que são -9007199254740991 e 9007199254740991. Para valores monetários, isso é mais do que suficiente, mesmo para grandes quantias. Ao trabalhar com centavos, todas as operações matemáticas são feitas com inteiros, evitando imprecisões.
Vamos ver um exemplo prático:
// Converter reais para centavos
function reaisParaCentavos(valorEmReais) {
return Math.round(valorEmReais * 100);
}
// Somar valores com precisão
const preco1 = reaisParaCentavos(10.99); // 1099
const preco2 = reaisParaCentavos(5.50); // 550
const totalCentavos = preco1 + preco2; // 1649
const totalReais = (totalCentavos / 100).toFixed(2); // "16.49"
console.log(totalReais); // "16.49"
Note que usamos Math.round ao converter para centavos para evitar problemas de arredondamento na multiplicação. Alternativamente, podemos usar parseInt após multiplicar por 100, mas Math.round é mais seguro para casos como 0.29 * 100, que pode resultar em 28.999999999999996.
Outra abordagem é usar uma biblioteca como decimal.js ou big.js, que implementam aritmética decimal exata. No entanto, para a maioria dos casos, trabalhar com centavos é suficiente e não requer dependências externas.
toFixed e armadilhas
O método toFixed() é frequentemente usado para formatar números com um número fixo de casas decimais. No entanto, ele pode gerar resultados inesperados devido à imprecisão do ponto flutuante. Por exemplo:
console.log((0.1 + 0.2).toFixed(2)); // "0.30" (parece correto, mas o valor subjacente ainda é impreciso)
console.log((1.005).toFixed(2)); // "1.00" (esperado "1.01")
O caso de 1.005 é um exemplo clássico de armadilha: o número 1.005 é representado internamente como 1.0049999999999999, então toFixed(2) arredonda para baixo, resultando em "1.00". Isso pode causar erros em cálculos financeiros se não for tratado corretamente.
Outra armadilha é usar toFixed() para arredondar e depois realizar mais cálculos. O método retorna uma string, então você precisa convertê-la de volta para número, o que pode reintroduzir imprecisões. Além disso, toFixed() usa arredondamento de metade para cima ("round half up") de acordo com a especificação, mas devido à representação binária, o comportamento pode ser inesperado.
Para evitar essas armadilhas, recomenda-se:
- Sempre trabalhar com centavos (inteiros) para cálculos.
- Usar
toFixed()apenas para exibição, e não para cálculos. - Se precisar arredondar, faça a operação em centavos e depois divida por 100 para exibir.
- Considere usar uma função de arredondamento personalizada que lide com a imprecisão, como
Math.round((value + Number.EPSILON) * 100) / 100.
Exemplo de arredondamento seguro:
function arredondarParaCentavos(valor) {
return Math.round((valor + Number.EPSILON) * 100) / 100;
}
console.log(arredondarParaCentavos(1.005)); // 1.01
No entanto, essa técnica não é infalível, e a abordagem de centavos é mais robusta.
Boas práticas e observações finais
Ao lidar com dinheiro em JavaScript, a regra de ouro é: nunca use números de ponto flutuante para representar valores monetários. Em vez disso, use inteiros para a menor unidade monetária (centavos, cents, etc.) ou use uma biblioteca de decimal. Isso garante precisão e evita dores de cabeça em auditorias e transações.
Além disso, sempre que for exibir valores, formate-os adequadamente usando Intl.NumberFormat ou toLocaleString para respeitar o formato local (como vírgula decimal no Brasil). E lembre-se de que toFixed() retorna uma string, então converta com cuidado se for reutilizar o valor.
Por fim, teste suas funções com valores problemáticos conhecidos, como 0.1+0.2, 1.005, 0.29*100, para garantir que suas soluções estão corretas.
Referências
- MDN: Number
- MDN: Number.prototype.toFixed()
- MDN: Number.EPSILON
- MDN: Intl.NumberFormat
- Wikipedia: IEEE 754
- The Floating-Point Guide
Exercícios
- Explique por que
0.1 + 0.2não é igual a0.3em JavaScript. Dê um exemplo de código que demonstre isso. - Escreva uma função
somarEmCentavosque recebe dois valores em reais (strings ou números), converte para centavos, soma e retorna o resultado em reais como string com duas casas decimais. - Qual é o problema com
(1.005).toFixed(2)? Como você corrigiria para obter "1.01"? - Implemente uma função
calcularTrocoque recebe o valor pago e o total da compra, e retorna o troco em reais com duas casas decimais, usando a abordagem de centavos. Inclua uma validação para pagamento insuficiente. - Explique por que é recomendável usar inteiros para representar dinheiro em vez de floats, e cite pelo menos duas armadilhas do uso de
toFixed().
console.log(0.1 + 0.2); // 0.30000000000000004function somarEmCentavos(valor1, valor2) {
const centavos1 = Math.round(parseFloat(valor1) * 100);
const centavos2 = Math.round(parseFloat(valor2) * 100);
const soma = centavos1 + centavos2;
return (soma / 100).toFixed(2);
}
function arredondar(valor) {
return Math.round((valor + Number.EPSILON) * 100) / 100;
}
console.log(arredondar(1.005).toFixed(2)); // "1.01"
function calcularTroco(pago, total) {
const pagoCentavos = Math.round(parseFloat(pago) * 100);
const totalCentavos = Math.round(parseFloat(total) * 100);
if (pagoCentavos < totalCentavos) {
throw new Error('Pagamento insuficiente');
}
const trocoCentavos = pagoCentavos - totalCentavos;
return (trocoCentavos / 100).toFixed(2);
}