Rarezas de JavaScript: lo que parece un fallo y no lo es

JavaScript tiene fama de hacer cosas absurdas: sumar dos decimales y equivocarse, ordenar mal una lista de números o decir que «no es un número» es un número. Aquí están las rarezas más famosas, ejecutadas de verdad, con la explicación de por qué pasan y la costumbre que las evita.

Todo lo que hay en esta página se ejecutó con Node.js 24 y la salida es la real. Ninguna de estas rarezas es un fallo: todas están escritas en la especificación del lenguaje, y casi todas vienen de decisiones tomadas en 1995 que ya no se pueden cambiar sin romper la web. Saber por qué pasan es lo que distingue a quien programa en JavaScript de quien pelea con él.

0.1 + 0.2 no da 0.3

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log((0.1).toFixed(20));
salida0.30000000000000004
false
0.10000000000000000555

Por qué: los números se guardan en binario, y 0.1 en binario tiene infinitas cifras, igual que 1/3 en decimal (0.3333…). Se guarda el número representable más cercano, que es un poco mayor que 0.1. Al sumar dos aproximaciones, el error se nota. No es cosa de JavaScript: Python, Java, C y Excel dan lo mismo, porque todos usan el estándar IEEE 754.

En la recta numérica, el valor 0.1 cae entre dos números representables en doble precisión; JavaScript guarda el más cercano, 0.100000000000000005550.1 no existe en binario: se guarda el double más cercano0.099999999999999991670.100000000000000005550.10000000000000001943el que se guarda0.1 exacto: no representableTres doubles consecutivos (la distancia real es mucho menor; está ampliada).

Entre dos números representables siempre hay un hueco. 0.1 cae en uno y se redondea al vecino más cercano. Esquema: decodigo.com.
Cómo evitarlo: no compares decimales con ===; usa una tolerancia, Math.abs(a - b) < Number.EPSILON, o redondea al mostrar con toFixed. Para dinero, trabaja en centavos con enteros: (10 + 20) / 100 da 0.3 exacto.

sort() ordena los números como si fueran texto

console.log([10, 1, 3, 25, 2].sort());
console.log([10, 1, 3, 25, 2].sort((a, b) => a - b));
salida[ 1, 10, 2, 25, 3 ]
[ 1, 2, 3, 10, 25 ]

Por qué: sin argumentos, sort convierte cada elemento en texto y los ordena como en un diccionario. «10» empieza por «1», que va antes que «2», así que 10 queda antes que 2. Tiene sentido para un array que puede mezclar cualquier cosa, pero sorprende con números.

sort sin comparador compara 10 y 2 como textos: el carácter 1 va antes que el 2, así que 10 queda antes que 2sort() sin comparador convierte a texto y compara letra a letra102«1» frente a «2»: 1 < 210 va antes253«2» frente a «3»: 2 < 325 va antesComo en un diccionario: «casa» va antes que «dos», aunque tenga más letras.

Se compara el primer carácter; los siguientes solo cuentan si el primero empata. Esquema: decodigo.com.

Con texto pasa algo parecido: el orden es el de los códigos Unicode, así que las mayúsculas van antes que las minúsculas y las letras con tilde, al final.

const nombres = ["ñu", "Zoe", "árbol", "oso"];
console.log([...nombres].sort());
console.log([...nombres].sort((a, b) => a.localeCompare(b, "es")));
salida[ 'Zoe', 'oso', 'árbol', 'ñu' ]
[ 'árbol', 'ñu', 'oso', 'Zoe' ]
Cómo evitarlo: pasa siempre un comparador: (a, b) => a - b para números y a.localeCompare(b, "es") para texto en español. Y recuerda que sort modifica el array original; si no quieres eso, usa toSorted, que devuelve una copia ordenada.

map(parseInt) devuelve NaN

console.log(["1", "7", "11"].map(parseInt));
console.log(["1", "7", "11"].map(Number));
salida[ 1, NaN, 3 ]
[ 1, 7, 11 ]

Por qué: map llama a la función con tres argumentos: el elemento, su posición y el array. parseInt acepta dos: el texto y la base en la que está escrito. Así que la posición acaba haciendo de base.

map pasa a parseInt el texto y su posición; parseInt la interpreta como base: 1 en base 0 da 1, 7 en base 1 da NaN y 11 en base 2 da 3map llama a parseInt con dos argumentos: el valor y la posiciónmap le pasaparseInt lo lee comoresultado(«1», 0)«1» en base 0 (= 10)1(«7», 1)«7» en base 1: no existeNaN(«11», 2)«11» en binario3

La base 0 se toma como «decide tú», que es decimal. La base 1 no existe, y en base 2 el «11» vale 3. Esquema: decodigo.com.
Cómo evitarlo: usa map(Number), o escribe la flecha completa con la base: map((s) => parseInt(s, 10)). Pasar una función tal cual a map solo es seguro si sabes cuántos argumentos acepta.

NaN no es igual a sí mismo

console.log(typeof NaN);
console.log(NaN === NaN);
console.log([NaN].indexOf(NaN), [NaN].includes(NaN));
console.log(Number.isNaN("hola"), isNaN("hola"));
salidanumber
false
-1 true
false true

Por qué: NaN significa «no es un número», pero es un valor del tipo number: el resultado de una operación numérica que no tiene sentido. El estándar IEEE 754 dice que NaN no es igual a nada, ni a sí mismo, porque dos operaciones fallidas no tienen por qué dar «lo mismo». Por eso indexOf, que usa ===, no lo encuentra, e includes, que usa otra comparación, sí.

Cómo evitarlo: para saber si algo es NaN, Number.isNaN(x). No uses el isNaN global, que convierte antes a número y por eso dice que "hola" es NaN.

typeof null es «object»

console.log(typeof null);
salidaobject

Por qué: es un error de la primera versión de JavaScript, escrita en diez días en 1995. Los valores llevaban una etiqueta de tipo en sus bits bajos, la de los objetos era 0, y null se representaba como el puntero nulo, que es todo ceros. Años después se propuso corregirlo en una nueva versión del estándar, y se descartó porque rompía demasiadas páginas.

Cómo evitarlo: para comprobar si algo es null, compáralo directamente: x === null. Y si lo que quieres saber es si es un objeto de verdad, x !== null && typeof x === "object".

[] + {} y otras sumas imposibles

console.log([] + []);
console.log([] + {});
console.log([1, 2] + [3]);
console.log("b" + "a" + +"a" + "a");
console.log("5" - 2, "5" + 2, true + true);
salida[object Object]
1,23
baNaNa
3 52 2

La primera línea imprime una cadena vacía. Por qué: el + primero convierte los objetos y los arrays en valores simples. Un array se convierte en sus elementos separados por comas ([] queda en "" y [1, 2] en "1,2"), y un objeto en "[object Object]". Después, si alguno de los dos es texto, concatena; si no, suma como números.

El operador más convierte primero objetos y arrays a primitivos; si alguno de los dos es texto concatena, y si no, convierte a número y sumaQué decide el operador + antes de sumara + bobjetos y arrays pasan a primitivo¿alguno de los dos es texto?sínoconcatena como texto[] + {} da «[object Object]»convierte a número y sumatrue + true da 2

La regla del +. El resto de operadores aritméticos (-, *, /) siempre convierten a número. Esquema: decodigo.com.

El «baNaNa» sale del +"a" del medio: un + delante de un valor lo convierte en número, y "a" como número es NaN, que después se concatena como texto. Y si has visto que {} + [] da 0 en la consola, también es verdad, pero por otro motivo: al principio de una línea, {} se lee como un bloque de código vacío, no como un objeto, y lo que queda es +[], que es 0. Lo comprobamos con eval("{} + []"), que da 0, frente a eval("({} + [])"), que da "[object Object]".

Cómo evitarlo: no sumes cosas de tipos distintos. Convierte primero con Number(...) o String(...), y para unir texto usa plantillas: `${a} ${b}`. TypeScript marca la mayoría de estos casos como error.

null >= 0 es verdadero, pero null == 0 no

console.log(null > 0, null == 0, null >= 0);
console.log(undefined > 0, undefined == 0, undefined >= 0);
console.log([] == ![]);
salidafalse false true
false false false
true

Por qué: los operadores > y >= convierten null en número, que es 0, así que null >= 0 es 0 >= 0. Pero == tiene reglas propias: null solo es igual a null y a undefined, y a nada más. Y undefined como número es NaN, que pierde en todas las comparaciones. El [] == ![] es la misma historia: ![] es false (un array es verdadero), y al comparar con == los dos lados acaban convertidos en 0.

Cómo evitarlo: usa siempre ===, y no compares con < o > valores que puedan ser null o undefined sin comprobarlos antes. Lo explicamos en la lección 2.

Un return que devuelve undefined

function crearUsuario() {
  return
  {
    nombre: "Ana"
  };
}
console.log(crearUsuario());
salidaundefined

Por qué: JavaScript inserta puntos y comas automáticamente, y una de sus reglas dice que tras return seguido de un salto de línea va un punto y coma. La función devuelve undefined y lo de abajo es un bloque de código que nunca se ejecuta. Lo contrario también pasa: si una línea empieza por [ o (, se pega a la anterior.

const a = 1
const b = a
[1, 2].forEach((n) => console.log(n))
salidaTypeError: Cannot read properties of undefined (reading 'forEach')

Se ha leído como const b = a[1, 2].forEach(...): la propiedad 2 del número 1, que no existe.

Cómo evitarlo: abre la llave en la misma línea que el return (return {), y pon los puntos y comas, o deja que los ponga un formateador como Prettier.

Math.max() es menor que Math.min()

console.log(Math.max(), Math.min());
console.log(Math.max(...[]));
salida-Infinity Infinity
-Infinity

Por qué: tiene lógica matemática. Math.max empieza con el valor más pequeño posible y lo va superando con cada argumento; sin argumentos, devuelve ese valor inicial. Es el mismo motivo por el que una suma vacía vale 0 y un producto vacío vale 1.

Cómo evitarlo: si el array puede estar vacío, compruébalo antes de llamar a Math.max(...lista), o recibirás -Infinity como si fuera un dato.

Arrays con huecos

console.log(new Array(3));
console.log(new Array(3, 4));
console.log(new Array(3).map((_, i) => i));
console.log(Array.from({ length: 3 }, (_, i) => i));
const b = [1, 2, 3];
delete b[1];
console.log(b, b.length);
salida[ <3 empty items> ]
[ 3, 4 ]
[ <3 empty items> ]
[ 0, 1, 2 ]
[ 1, <1 empty item>, 3 ] 3

Por qué: con un solo número, new Array(3) crea un array de longitud 3 sin elementos; con dos, crea un array con esos dos elementos. Los huecos no son undefined: no existen, y por eso map se los salta. delete hace lo mismo: deja un hueco y no cambia la longitud.

Cómo evitarlo: para crear arrays, Array.from({ length: n }, ...) o new Array(n).fill(0). Para quitar un elemento, splice o filter, nunca delete.

El bucle que imprime siempre 3

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 0);
}
salidavar: 3
var: 3
var: 3
let: 0
let: 1
let: 2

Por qué: las funciones de setTimeout se ejecutan cuando el bucle ya ha terminado. Con var hay una sola variable i para todo el bucle, y cuando las funciones la leen ya vale 3. Con let, cada vuelta tiene su propia variable, y cada función recuerda la suya. Esto se explica a fondo en la lección de funciones y closures.

Con var, las tres funciones del bucle apuntan a la misma variable i, que al final vale 3; con let, cada vuelta tiene su propia j con 0, 1 y 2var comparte una sola variable; let crea una en cada vueltafor (var i …)función 1función 2función 3i = 3for (let j …)función 1j = 0función 2j = 1función 3j = 2Las funciones se ejecutan cuando el bucle ya terminó: con var, todas ven el valor final.

Las funciones no copian el valor: guardan una referencia a la variable. Esquema: decodigo.com.
Cómo evitarlo: no uses var. Con let y const este problema desaparece.

this se pierde

const contador = {
  cuenta: 0,
  sumar() {
    this.cuenta++;
    return this.cuenta;
  },
};
console.log(contador.sumar());
const suelta = contador.sumar;
suelta();
salida1
salidaTypeError: Cannot read properties of undefined (reading 'cuenta')

Por qué: en JavaScript, this no depende de dónde se escribió la función, sino de cómo se llama. contador.sumar() tiene un objeto delante del punto, y ese es this. suelta() no tiene nada delante, así que this es undefined. Pasa a menudo sin darse cuenta, al pasar un método como callback: setTimeout(contador.sumar, 100) o boton.addEventListener("click", objeto.metodo).

Cómo evitarlo: envuelve el método en una flecha, () => contador.sumar(), o átalo con contador.sumar.bind(contador). Las reglas completas están en la lección de clases y this.

Los meses empiezan en cero

const d = new Date(2026, 9, 19);
console.log(d.getMonth(), d.toDateString());
console.log(new Date(2026, 1, 31).toDateString());
console.log(new Date("2026-10-19").getDate(), new Date("2026-10-19T00:00").getDate());
salida (en México, UTC-6)9 Mon Oct 19 2026
Tue Mar 03 2026
18 19

Tres sorpresas en tres líneas. Los meses van de 0 a 11, así que octubre es el 9; los días, en cambio, empiezan en 1. Una fecha imposible no da error, se desborda: el 31 de febrero se convierte en el 3 de marzo. Y una fecha escrita solo con el día, "2026-10-19", se interpreta como medianoche en UTC, que en México todavía es el día 18; si añades la hora, se interpreta en la hora local. Todo se copió de Java 1.0, que tenía los mismos defectos y los corrigió hace años.

Cómo evitarlo: para mostrar fechas, Intl.DateTimeFormat o toLocaleDateString("es-MX"). Para cálculos serios, una biblioteca o Temporal, la nueva API de fechas que corrige todo esto, cuando esté disponible en todas partes: Node 24, por ejemplo, todavía no la trae. Más en el ejemplo de fechas.

Un emoji mide 2

console.log("👍".length, [..."👍"].length);
const familia = "👨‍👩‍👧";
console.log(familia.length, [...familia].length);
const s = new Intl.Segmenter("es", { granularity: "grapheme" });
console.log([...s.segment(familia)].length);
salida2 1
8 5
1

Por qué: las cadenas de JavaScript cuentan unidades de UTF-16, no letras. Los emojis no caben en una unidad y ocupan dos. Recorrer la cadena con [...s] cuenta caracteres Unicode, pero el emoji de la familia está hecho de tres personas unidas por caracteres invisibles: cinco caracteres que se ven como uno. Solo Intl.Segmenter cuenta lo que una persona llamaría «un carácter».

Cómo evitarlo: si limitas la longitud de un texto que escribe un usuario (un nombre, un tuit), cuenta con Intl.Segmenter, y no cortes cadenas con slice por posiciones arbitrarias: puedes partir un emoji por la mitad.

toFixed y Math.round redondean raro

console.log(1.005.toFixed(2));
console.log((0.5).toFixed(0), (1.5).toFixed(0), (2.5).toFixed(0));
console.log(Math.round(2.5), Math.round(-2.5));
salida1.00
1 2 3
3 -2

Por qué: 1.005 se guarda como 1.00499999999999989..., igual que le pasaba a 0.1, y redondeado a dos decimales da 1.00. Y Math.round redondea los .5 siempre hacia arriba, hacia el infinito positivo: 2.5 sube a 3, pero -2.5 sube a -2. No es el redondeo «alejándose del cero» que se enseña en el colegio, ni el del banquero que usa Python.

Cómo evitarlo: para importes, trabaja con enteros (centavos) y formatea al final con Intl.NumberFormat. Si necesitas un redondeo concreto con negativos, escríbelo tú: Math.sign(x) * Math.round(Math.abs(x)).

Existe el -0

console.log(0 === -0, Object.is(0, -0));
console.log(1 / 0, 1 / -0);
console.log(String(-0), JSON.stringify(-0));
salidatrue false
Infinity -Infinity
0 0

Por qué: en IEEE 754 el signo es un bit aparte, así que hay un cero positivo y uno negativo. Aparece, por ejemplo, al redondear -0.4. === los considera iguales y al convertirlos a texto los dos son "0", pero se comportan distinto al dividir. Object.is es la comparación que sí los distingue (y la que dice que NaN es igual a NaN).

JSON.stringify se calla cosas

console.log(JSON.stringify({ a: undefined, b: () => 1, c: NaN, d: new Date(0), e: Infinity }));
console.log(JSON.stringify([undefined, () => 1]));
salida{"c":null,"d":"1970-01-01T00:00:00.000Z","e":null}
[null,null]

Por qué: JSON es un formato más pobre que JavaScript: no tiene undefined, ni funciones, ni NaN, ni Infinity, ni fechas. En un objeto, las propiedades con undefined o funciones desaparecen; en un array se convierten en null; NaN e Infinity pasan a null, y las fechas a texto, que al leerlas de vuelta con JSON.parse siguen siendo texto. Y un BigInt directamente da error.

Cómo evitarlo: si guardas y recuperas datos con JSON, comprueba que lo que vuelve es lo que esperas. Para las fechas, conviértelas de nuevo con new Date(texto). Más en el ejemplo de JSON.

forEach no espera a async

const espera = (ms) => new Promise((ok) => setTimeout(ok, ms));
const lista = [3, 1, 2];
lista.forEach(async (n) => {
  await espera(n * 10);
  console.log("terminó", n);
});
console.log("después del forEach");
salidadespués del forEach
terminó 1
terminó 2
terminó 3

Por qué: forEach llama a la función por cada elemento y no mira lo que devuelve. Una función async devuelve una promesa al instante, así que forEach termina sin esperar a ninguna, el código de después se ejecuta primero, y las tareas acaban en el orden en el que terminan, no en el de la lista. Lo comprobamos diez veces y el orden fue siempre el mismo.

Cómo evitarlo: para ir de una en una, for (const n of lista) { await ... }; para lanzarlas todas a la vez y esperar a que terminen, await Promise.all(lista.map(async (n) => ...)). Está explicado en la lección de asincronía.

Resumen: la regla detrás de cada rareza

RarezaLa causaLa costumbre que la evita
0.1 + 0.2, toFixedLos decimales se guardan en binarioTolerancia al comparar, enteros para el dinero
sort()Sin comparador, compara textoPasar siempre un comparador
map(parseInt)map pasa más argumentos de los que creesEscribir la flecha completa
NaN, -0IEEE 754Number.isNaN, Object.is
[] + {}, ==Conversiones automáticas=== y convertir a mano
return + saltoPunto y coma automáticoLlave en la misma línea, formateador
Huecos, varDiseño antiguoArray.from, let y const
thisDepende de cómo se llama la funciónFlechas o bind
FechasCopiado de Java 1.0Intl y, pronto, Temporal
EmojisLas cadenas cuentan unidades UTF-16Intl.Segmenter
forEach + asyncforEach no espera promesasfor...of o Promise.all

¿Quieres comprobar si te las sabes? Varias aparecen en el test de JavaScript.

Todo el código de esta página se ejecutó con Node.js 24.22 en un contenedor limpio; las salidas y los errores están copiados de esa ejecución.