{0,0}

Cuando los números mienten: Precisión numérica en TypeScript

¿Cómo se representan los números en una computadora? ¿Por qué 0.1 + 0.2 no es 0.3 y cómo afecta a nuestras aplicaciones?

Cuando estudiamos programación e ingeniería de software dedicamos tiempo a estudiar la foto completa: sistemas a escala, arquitectura de software, patrones de diseño y código modular. Pero una vez nos encontramos de frente con el mundo real nos encontramos con que muchos problemas no vienen de la arquitectura o el diseño a alto nivel de nuestros sistemas sino que se esconden en detalles cotidianos que podemos pasar por alto más fácilmente de lo que pensamos.

Algunos clásicos ejemplos para este tipo de “trampas” son:

  • Fechas, timestamps y zonas horarias: El dolor de cabeza de gestionar la hora UTC en el servidor frente a clientes en diferentes zonas horarias, darse cuenta demasiado tarde de que una base de datos no almacenó la zona horaria correctamente (o ni siquiera lo tuvimos en cuenta en primer lugar).
  • La precisión en el manejo de números decimales: O, como me gusta llamarle, asumir que una computadora puede calcular 0.1 + 0.2 sin problemas.
  • Comportamientos por defecto implícitos: aquellas cosas que ocurren sin que pidamos explícitamente. Por ejemplo:
    • En JavaScript Number("") devuelve 0 aunque no le hayamos pedido convertir un string vacío a 0 específicamente.
    • En SQL la ausencia de cláusulas ORDER BY hace parecer que por defecto los datos se ordenan por su creación pero en realidad está más cerca de ser una casualidad del motor de base de datos. Incluso si incluimos ORDER BY created_at implícitamente tenemos un ASC (orden ascendente) por defecto.

Si bien las zonas horarias y los comportamientos por defecto merecen un análisis más profundo, hoy nos centraremos exclusivamente en la precisión numérica. Si tu aplicación maneja dinero, saldos de tokens o cantidades fraccionarias, tratar los números sin tener en cuenta este detalle es una bomba de tiempo.

Exploremos por qué sucede esto, cómo sistemas como aplicaciones blockchain lo esquivan y cómo manejar de forma segura la precisión exacta desde TypeScript hasta tu base de datos PostgreSQL.

La raíz del problema: La trampa del 0.1 + 0.2

console.log(0.1 + 0.2); // 0.30000000000000004

¿Qué pasó aquí? ¿Por qué el resultado no es 0.3? Para los humanos esto es absurdo, para la computadora es completamente lógico.

JavaScript utiliza el estándar IEEE 754 para números de coma flotante de doble precisión.

Se llama “doble precisión” porque históricamente se compara contra otro formato más chico: simple precisión. En IEEE 754, los formatos más comunes son float (32 bits) y double (64 bits).

Un float de 32 bits se distribuye:

  • 1 bit → signo
  • 8 bits → exponente
  • 23 bits → fracción/significand

Un double de 64 bits usa:

  • 1 bit → signo
  • 11 bits → exponente
  • 52 bits → fracción/significand

Las computadoras piensan en binario (base 2), mientras que los humanos piensan en decimal (base 10). En base 10, ciertas fracciones no se pueden representar de forma precisa; por ejemplo, 1/3 se convierte en un decimal periódico (0.3333…). De manera similar, en binario, fracciones como 1/10 (0.1) o 1/5 (0.2) se convierten en fracciones binarias infinitas y periódicas. Debido a que la memoria del hardware es finita, la computadora debe truncar esa secuencia infinita. Ese truncamiento microscópico es donde nace el error de redondeo. Si bien una diferencia de 0.00000000000000004 puede no importar al calcular el diseño de una animación, importa enormemente al calcular libros contables o liquidaciones de contratos inteligentes.

Veamos esto en detalle con las conversiones a mano.

De decimal a binario: multiplicar por 2

Para convertir la parte fraccionaria de un número decimal a binario, se multiplica repetidamente por 2 y se toma el dígito entero que aparece en cada paso. Con 0.625 (un caso “amigable”):

0.625 × 2 = 1.25  → 1 (la parte fraccionaria es 0.25 y pasa a multiplicarse por 2)
0.25  × 2 = 0.5   → 0 (la parte fraccionaria es 0.5 y pasa a multiplicarse por 2)
0.5   × 2 = 1.0   → 1  (llegamos a 0, no tenemos parte fraccionaria, terminamos)

Leyendo los dígitos de arriba hacia abajo: 0.625 = 0.101 en binario. La representación es finita porque 0.625 = 5/8, y 8 es una potencia de 2. Esa es la regla general: una fracción decimal tiene representación binaria finita solo si su denominador (simplificado) es una potencia de 2.

Cómo queda dentro del double

Con el número ya en binario, armar el double es cuestión de llenar los tres campos que vimos arriba. Primero se normaliza: se corre el punto hasta dejar un único 1 a la izquierda (notación científica, pero en base 2). Ese 1 inicial no se guarda porque siempre está (se lo llama bit implícito).

El exponente sale directo de la normalización: es la cantidad de lugares que moviste el punto (positivo si lo corriste hacia la izquierda, negativo si fue hacia la derecha). Pero como el campo de exponente no tiene bit de signo propio, IEEE 754 no guarda el exponente real sino el exponente más un sesgo de 1023. ¿Por qué 1023? Los 11 bits del campo permiten 2048 valores (0 a 2047); reservando los dos extremos para casos especiales (el 0 para ceros y subnormales, el 2047 para Infinity y NaN), quedan 2046 valores para repartir mitad y mitad entre exponentes negativos y positivos: de −1022 a +1023. El sesgo 1023 (que es 2¹⁰ − 1, el punto medio del rango) corre todo ese rango hacia arriba para que quede como enteros sin signo. Un bonus de este truco: dos doubles positivos se pueden comparar bit a bit como si fueran enteros, sin lógica extra para exponentes negativos.

Para 0.625:

0.625 = 0.101₂ = 1.01 × 2⁻¹   (normalizado)

signo       → 0                (positivo)
exponente   → −1 + 1023 = 1022 → 01111111110
significand → 0100000000000000000000000000000000000000000000000000
              (el "01" que sigue al 1. implícito, rellenado con ceros)

Y para un número con parte entera como 14.625 (= 1110.101₂, porque 14 = 1110₂):

14.625 = 1110.101₂ = 1.110101 × 2³   (normalizado)

signo       → 0                (positivo)
exponente   → 3 + 1023 = 1026  → 10000000010
significand → 1101010000000000000000000000000000000000000000000000

En ambos casos sobran bits: los 52 del significand alcanzan de sobra y el resto se rellena con ceros. El número se guarda exacto. El problema de 0.1 es justamente que su expansión binaria no termina nunca, así que no hay relleno de ceros posible: los 52 bits se llenan con el patrón periódico y hay que cortar.

Ahora el mismo proceso con 0.1:

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0  (¡volvimos a 0.2! el ciclo se repite para siempre)

Nunca llegamos a 0: 0.1 = 1/10, y 10 no es potencia de 2. El resultado es infinito y periódico:

0.1 = 0.0001100110011001100110011...₂  (el patrón "0011" se repite infinitamente)

Como un double solo tiene 53 bits de precisión para el significand (los 52 bits guardados más el 1 implícito), la computadora corta esa secuencia infinita y redondea el último bit. Lo que guarda ya no es 0.1, es la aproximación más cercana posible.

De binario a decimal: potencias negativas de 2

El camino inverso es sumar potencias negativas de 2 por cada bit encendido: la primera posición después del punto vale 2⁻¹ = 0.5, la segunda 2⁻² = 0.25, la tercera 2⁻³ = 0.125, y así sucesivamente.

0.101₂ = 1×0.5 + 0×0.25 + 1×0.125 = 0.625 ✓

Si aplicamos este proceso a los 53 bits que la computadora realmente guardó para 0.1, obtenemos su valor exacto. Es un decimal largo (aunque finito, porque los bits guardados son finitos) y ligeramente mayor que 0.1:

0.1 se guarda como → 0.1000000000000000055511151231257827021181583404541015625
0.2 se guarda como → 0.2000000000000000111022302462515654042363166809082031250

Notá que 0.2 arrastra el doble de error que 0.1: en binario, 0.2 es exactamente la misma secuencia periódica que 0.1 desplazada un lugar, así que su error de redondeo también se duplica.

Entonces, ¿por qué console.log(0.1) imprime 0.1 y no ese número larguísimo? Porque JavaScript, al imprimir, elige el decimal más corto que redondea de vuelta al mismo double. El error está ahí, solo que escondido detrás del formateo.

La suma 0.1 + 0.2, paso a paso

Con esto ya podemos reconstruir la escena del crimen:

  1. Antes de sumar, ya hay error. La CPU no suma 0.1 + 0.2, suma las dos aproximaciones de arriba, ambas ligeramente pasadas de su valor real.
  2. La suma exacta de esas aproximaciones es:
  0.1000000000000000055511151231257827021181583404541015625
+ 0.2000000000000000111022302462515654042363166809082031250
= 0.3000000000000000166533453693773481063544750213623046875
  1. Ese resultado tampoco entra en 53 bits, así que hay que redondearlo de nuevo al double más cercano. Los dos candidatos son:
0.29999999999999998889776975374843459576368331909179687500  (el double que representa al literal 0.3)
0.30000000000000004440892098500626161694526672363281250000  (el siguiente double)

La suma exacta cae justo en el punto medio entre ambos, y la regla de desempate de IEEE 754 (redondear al candidato cuyo último bit es 0, conocida como round half to even) elige el segundo. El resultado final guardado es 0.30000000000000004440....

  1. Al imprimir, JavaScript busca el decimal más corto que identifica a ese double: 0.30000000000000004. Y como el literal 0.3 se guarda como el otro double (el que queda apenas por debajo), la comparación falla:
console.log(0.1 + 0.2 === 0.3); // false

En resumen: hubo tres redondeos (al guardar 0.1, al guardar 0.2 y al guardar la suma) y los errores no se cancelaron entre sí, sino que se acumularon hacia arriba. No es un bug de JavaScript: cualquier lenguaje que use IEEE 754 de 64 bits (Python, Java, C++, Go…) da exactamente el mismo resultado.

Ampliando el panorama con BigInt

Además de number, JavaScript también tiene BigInt para manejar números arbitrariamente grandes. La diferencia radica en:

number

  • Representa números de punto flotante de 64 bits (estándar IEEE 754).
  • Como todas las otras representaciones de números flotantes que siguen el IEEE 754, no puede representar todo el universo de números fraccionarios de forma precisa.
  • Rango seguro: solo puede representar con seguridad enteros hasta 2^{53} - 1. Este límite está disponible como Number.MAX_SAFE_INTEGER. Por ejemplo:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 -> no es seguro

BigInt

  • Enteros de precisión arbitraria. Puede crecer tanto como lo permita la memoria disponible de tu sistema.
  • No maneja decimales de forma nativa. Si hacés 5n / 2n el resultado es 2n (agregar n al número es una forma de indicarle a JavaScript que es un bigint, otra opción es usar el constructor BigInt()). Además, no podés mezclar variables number y bigint en operaciones matemáticas sin hacer un casting explícito u obtendrás un error como Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions.
  • Como su nombre lo indica, solo maneja números enteros. No admite partes fraccionarias.
console.log(BigInt(Number.MAX_SAFE_INTEGER) + 2n); // 9007199254740993n -> es seguro

¿Cómo lo resuelven las blockchains?

Ahora, problemas como este tienen que estar presentes en cualquier sistema que maneje dinero, saldos de tokens o cantidades fraccionarias.

En una fintech tradicional, si calculás mal un monto, muchas veces tenés capas para corregirlo: base de datos interna, conciliación bancaria, reversos, ajustes contables, soporte, chargebacks o correcciones manuales. Sigue siendo grave, pero el sistema suele tener mecanismos administrativos para reparar.

En blockchain, en cambio, cuando firmás y enviás una transacción válida, esa transacción puede quedar ejecutada on-chain y no hay un “administrador de la red” que la revierta. Esto sumado a que mueven grandes cantidades de dinero (y cada vez más), nos obliga a tener una precisión exacta en los cálculos.

Es por esto que ERC-20 (el estándar de tokens fungibles en Ethereum) define que cada token tiene un número de decimales fijo (podés leer más aquí). Por ejemplo, USDC usa 6 decimales, lo que significa que 1 USDC es igual a 1,000,000 unidades. ETH usa 18 decimales, lo que significa que 1 ETH es igual a 1,000,000,000,000,000,000 wei. Esto significa que un balance de 0.1 ETH es en realidad almacenado como 100,000,000,000,000,000 wei en la blockchain y solo formateado como 0.1 ETH para una mejor comprensión humana.

De esta forma, cualquier número fraccionario se puede representar de forma exacta a partir de la combinación de un número entero y una cantidad de decimales. En la blockchain cada contrato define su propia escala de decimales.

Operaciones con Ejemplos

1. Aritmética de punto fijo nativa con BigInt

Para sumar 0.1 ETH y 0.2 ETH de forma segura (sin errores de punto flotante), primero escalamos a su representación entera (wei):

const decimals = 18n;
const scale = 10n ** decimals; // 1_000_000_000_000_000_000n

// Representación de 0.1 ETH y 0.2 ETH como BigInt
const amountA = 100000000000000000n; // 0.1 ETH
const amountB = 200000000000000000n; // 0.2 ETH

const totalWei = amountA + amountB; // 300000000000000000n

// Vuelve a decimal solo para mostrar en UI
const totalEth = Number(totalWei) / Number(scale); 
console.log(totalEth); // 0.3

2. Usando librerías matemáticas para lógica compleja

BigInt es perfecto para sumas/restas de enteros, pero dividir o calcular tasas compuestas se vuelve complejo porque BigInt descarta decimales. Para lógica más avanzada, se recomienda usar librerías especializadas como bignumber.js o decimal.js:

import BigNumber from 'bignumber.js';

// Operaciones decimales exactas
const a = new BigNumber(0.1);
const b = new BigNumber(0.2);
console.log(a.plus(b).toString()); // "0.3"

// Parseo y divisiones seguras con balances crudos de 18 decimales
const ethBalanceRaw = new BigNumber("1234567890123456789"); // valor wei crudo
const ethUnit = ethBalanceRaw.dividedBy(new BigNumber(10).pow(18));

console.log(ethUnit.toString()); // "1.234567890123456789"

Guardando decimales largos: Estrategia en PostgreSQL

Cuando llevás datos de alta precisión desde tu aplicación hacia la base de datos, elegir el tipo correcto determina si tu libro contable preserva la precisión.

Opción A: Guardar como String / VARCHAR

Común en indexadores de blockchain: el número entero gigantesco (tipo uint256 de Solidity) se almacena directamente como texto.

Ventajas: Sin riesgo de perder precisión ni truncar durante el guardado. Soporta cualquier cantidad de dígitos.

Desventajas: Perdés la posibilidad de hacer cálculos nativos de base de datos eficientes (SUM(), AVG(), rangos >, <) sin castear explícitamente en cada query.

Opción B: Usar NUMERIC/DECIMAL nativo

PostgreSQL ofrece tipos NUMERIC para alta precisión exacta definida por el usuario.

CREATE TABLE asset_balances (
    id SERIAL PRIMARY KEY,
    -- Hasta 78 dígitos totales, con exactamente 18 decimales
    token_balance NUMERIC(78, 18) NOT NULL
);

Profundizando: límites de precisión en Postgres

  • Límite máximo: hasta 131,072 dígitos antes del punto decimal y 16,383 después.
  • ¿Qué pasa si pongo un NUMERIC(1000, 18) “por las dudas”?
    Tentador, pero trae costos:
    • Almacenamiento: NUMERIC es de largo variable, almacena en bloques de 4 dígitos por 2 bytes + cabecera de 4-8 bytes. Si tu límite es enorme, el espacio y la eficiencia de la caché caen en picada.
    • Performance: Todas las operaciones sobre NUMERIC son por software en Postgres, no usando el hardware flotante del servidor. A mayor precisión, mayor carga de CPU.
    • Índices: Indexar columnas NUMERIC gigantes inflará tus índices, volviendo lecturas y escrituras más lentas.

Práctica estándar: Elegí límites realistas según los activos que vas a manejar. Para el ecosistema Ethereum, NUMERIC(78, 18) es el estándar (suficiente para soportar el máximo valor de un uint256 de Solidity, ~$1.15 \times 10^77$).

Resumen: Sistemas a prueba de futuro

Para prevenir errores de precisión numérica en producción:

  1. Contratos API con Strings: Cuando transmitís grandes números o saldos por JSON, siempre serializalos como string; los parsers de JSON convierten los números automáticamente a float y podés perder precisión antes de llegar a tu código.
  2. La matemática en la capa adecuada: No usés primitivas number de JS/TS para cuentas financieras. Usá BigInt para cálculos escalares, o librerías como bignumber.js para fracciones complejas.
  3. Nombres explícitos: Evitá mezclar unidades sin querer; usá nombres como balanceEnWei o montoEnUnidadesUsdc en vez de ambiguos balance o monto.
  4. Tipado de base de datos a medida: Usá NUMERIC(precision, scale) en cuentas donde necesitás matemática y agregaciones; reservá VARCHAR/TEXT solo para logs inmutables de eventos.

Espero haber aportado a tu comprensión sobre el manejo de números, enteros y decimales en las computadoras. ¡Nos vemos en el siguiente post!