Siete modismos comunes de JavaScript que introducen silenciosamente errores en el futuro
Explica cómo los patrones cotidianos de JavaScript, como las comprobaciones de valor verdadero, la cadena opcional y la sintaxis de propagación, ocultan suposiciones que se rompen en silencio a medida que el código evoluciona.
Una solicitud de integración reduce doce líneas de JavaScript a cuatro. La versión actualizada utiliza la cadena opcional, una comprobación de valor verdadero, la expansión de objetos y una tabla de consulta ordenada, lo que facilita la revisión. El código resultante es más conciso, se lee de forma natural y funciona correctamente con todos los datos que la aplicación maneja actualmente.
Meses después, el valor 0 comienza a ser aceptado como un valor de configuración válido. Una respuesta de API deja de incluir un campo que antes siempre estaba presente. Una tarea en segundo plano que anteriormente procesaba docenas de registros ahora debe manejar miles. Un objeto anidado es reutilizado en un lugar donde se suponía que ya había sido copiado. Ninguno de estos eventos posteriores parece estar relacionado con la refactorización anterior, pero cada uno revela una suposición que el código compacto había incorporado sin indicarlo explícitamente.
Este es precisamente el motivo por el cual ciertos modos de expresión en JavaScript son arriesgados. Rara vez parecen incorrectos en el momento, y los desarrolladores experimentados los utilizan precisamente porque son prácticos, expresivos y precisos dentro de los requisitos actuales. Los problemas surgen cuando esa practicidad fusiona silenciosamente varios estados distintos en un único comportamiento, o cuando la sintaxis que parece una garantía de seguridad es en realidad solo un abreviatura.
Por lo tanto, la verdadera pregunta durante la revisión no es si un fragmento de código es ingenioso o conciso, sino si el código expone sus suposiciones con suficiente claridad como para que los cambios futuros causen un fallo evidente en lugar de un resultado incorrecto en silencio.
1. Las comprobaciones de valores “verdaderos” reducen diferentes valores a la misma decisión
Imagínese un parámetro de configuración que controla cuántas veces se permite reintentar una operación.
function getRetryCount(config) {
if (!config.retries) {
return 3;
}
return config.retries;
}
Esta lógica funciona bien siempre y cuando retries sea un número positivo o esté ausente. Pero en cuanto alguien agrega la posibilidad de desactivar por completo las reintentos estableciendo el valor en 0, la función sobrescribe silenciosamente esa elección explícita y devuelve 3 en su lugar. Las reglas de veracidad de JavaScript se están aplicando correctamente; simplemente esas reglas ya no coinciden con lo que realmente necesita el negocio.
Varios valores además de false se consideran falsos en JavaScript: 0, "", null, undefined y NaN cumplen con esta condición. Por lo tanto, una condición como if (!value) en realidad no está verificando si algo falta, sino si JavaScript lo convertiría a false, lo cual es una pregunta mucho más amplia que “¿está esto ausente?”
Una versión más precisa depende de cuál sea el contrato real.
function getRetryCount(config) {
if (config.retries === undefined) {
return 3;
}
return config.retries;
}
Y cuando tanto null como undefined están destinados a indicar “no se proporcionó nada”, la unión de valores nulos puede expresar esa intención de manera más clara.
const retries = config.retries ?? 3;
Esta distinción es de gran importancia para los campos de formulario, parámetros de paginación, precios, límites, indicadores de funcionalidades, contadores y otros ajustes numéricos. Un indicador llamado enabled puede legítimamente tener el valor false. Un campo de búsqueda puede legítimamente estar vacío. Un desplazamiento puede legítimamente ser 0. Tratar todos los valores falsos como “no proporcionados” elimina información en aras de la brevedad.
Nada de esto hace que las comprobaciones de veracidad estén inherentemente mal. Funcionan bien cuando el requisito se refiere realmente a la veracidad, por ejemplo, al decidir si mostrar una cadena opcional solo si no está vacía. Se convierten en un problema cuando el dominio tiene más estados distintos de los que la coerción booleana de JavaScript puede mantener.
La pregunta que vale la pena hacer durante la revisión es sencilla: ¿realmente esta condición necesita verificar la falsedad, o se está utilizando la veracidad como sustituto de una regla de negocio más específica?
2. La cadena opcional puede convertir datos dañados en datos faltantes silenciosamente
La cadena opcional se cuenta entre las adiciones más valiosas de JavaScript, ya que hace que la navegación por estructuras profundamente opcionales sea mucho menos problemática.
const avatarUrl = user.profile?.avatar?.url;
Cuando profile o avatar realmente pueden estar ausentes, esta es precisamente la situación para la cual existe el encadenamiento opcional. Obtener undefined tiene sentido aquí, ya que su ausencia es un resultado normal y esperado.
El problema comienza cuando se aplica el encadenamiento opcional a datos que se supone deben estar siempre presentes.
function getShippingCountry(order) {
return order.customer?.address?.country;
}
Supongamos que el contrato del sistema garantiza que cualquier pedido listo para enviar incluya un cliente y una dirección de envío. Si algún error en la cadena de procesamiento genera un pedido que carezca de uno de estos elementos, el encadenamiento opcional convierte silenciosamente lo que debería ser una violación del contrato en un simple undefined. Ese problema podría no manifestarse hasta mucho después, apareciendo como un cálculo fiscal incorrecto, una etiqueta de envío faltante o un comportamiento confuso en alguna parte no relacionada del código.
Un enfoque mejor en este caso podría ser fallar de inmediato, justo en el punto donde la suposición realmente importa.
function getShippingCountry(order) {
if (!order.customer?.address) {
throw new Error(
`Order ${order.id} is missing a required shipping address`
);
}
return order.customer.address.country;
}
Esta versión es más extensa, pero transmite más información. Indica a cualquiera que la lea posteriormente que la dirección no es solo un dato opcional que puede aparecer o no; su ausencia significa que el objeto en absoluto debe considerarse válido para esta operación.
El encadenamiento opcional también tiende a extenderse una vez que la incertidumbre se introduce en un tipo o en el contrato de una API. Un único campo incierto se convierte en foo?.bar, lo cual pronto se vuelve foo?.bar?.baz, y en poco tiempo todos los usuarios toleran silenciosamente la falta de datos en lugar de preguntarse por qué un campo que se supone necesario sigue desapareciendo. La sintaxis reduce las excepciones lanzadas, pero puede aumentar igualmente la ambigüedad general.
La decisión de si el encadenamiento opcional es la herramienta adecuada debe basarse en el modelo del dominio, no en la conveniencia. Si un valor es realmente opcional, el encadenamiento opcional es una forma clara de expresarlo. Si es obligatorio, permitir que undefined se propague más adelante en el programa puede hacer que sea mucho más difícil rastrear hasta dónde comenzó realmente el error.
3. La sintaxis de propagación parece una copia completa, pero los objetos anidados mantienen su identidad original
Propagar un objeto a otro nuevo es una forma compacta de escribir actualizaciones que parecen inmutables.
const updatedUser = {
...user,
name: "Umar",
};
En el nivel superior, esto realmente crea un objeto nuevo con los valores que se esperan. Ese éxito hace que los desarrolladores interpreten { ...object } como “clona este objeto”, cuando en realidad la operación solo duplica las propiedades que se encuentran directamente en ese objeto.
Todo lo que está anidado un nivel más adentro no recibe ese tratamiento.
const original = {
name: "Umar",
preferences: {
theme: "light",
},
};
const copy = { ...original };
copy.preferences.theme = "dark";
console.log(original.preferences.theme);
// "dark"
Aquí, copy es de hecho un objeto exterior distinto, pero copy.preferences sigue apuntando al mismo objeto que original.preferences. El operador de propagación duplicó la referencia, no la estructura que depende de ella.
Esto se vuelve peligroso rápidamente en la gestión de estado, ya que el código resultante mantiene aún la apariencia visual de una actualización inmutable.
const nextState = { ...state };
nextState.user.preferences.theme = "dark";
El objeto exterior es nuevo, pero user y preferences pueden ser exactamente los mismos objetos del estado anterior. Cualquier mecanismo que decida si se debe volver a renderizar o recalcular basándose en la igualdad de referencias puede terminar trabajando con un objeto híbrido: algunas partes recién creadas y otras modificadas en el mismo lugar. Mientras tanto, cualquier otro fragmento de código que aún mantenga una referencia al estado antiguo también observará silenciosamente dicha modificación.
Si solo una rama de un objeto anidado necesita cambiar realmente, copie esa rama intencionadamente en lugar de depender de una única expansión a nivel superior.
const nextState = {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: "dark",
},
},
};
Se necesita escribir más código, pero cada nivel que cambia recibe una nueva identidad, mientras que las partes que no se modificaron siguen compartiéndose de forma segura. Herramientas como Immer pueden suavizar este patrón cuando los árboles de estado se vuelven profundos y anidados, pero no eliminan el requisito subyacente: aún hay que saber qué referencias sobreviven a una actualización y cuáles no.
La sintaxis de propagación en sí no tiene defectos. El error surge al asumir que una copia superficial proporciona aislamiento profundo.
Cada vez que propague un objeto para protegerlo de mutaciones posteriores en otro lugar, deténgase y verifique qué referencias anidadas siguen compartiéndose después. Esa verificación es mucho más importante que el hecho de que la sintaxis utilice ....
4. El proceso de ida y vuelta mediante JSON.parse(JSON.stringify(...)) no es lo mismo que clonar
Un truco útil para evitar los problemas de copia superficial es esta línea sencilla:
const copy = JSON.parse(JSON.stringify(original));
Parece solucionar el problema de las referencias anidadas, ya que al parsear la cadena JSON se generan objetos completamente nuevos. Para datos que ya tienen la forma de JSON puro, incluso puede ofrecerte exactamente lo que esperabas.
El problema radica en considerar ese resultado como una garantía general de clonación profunda.
JSON solo entiende un modelo de datos limitado. Tan pronto como tu objeto contiene valores fuera de ese modelo, la serialización puede modificarlos en silencio, eliminarlos o generar errores.
Toma este objeto como ejemplo:
const value = {
createdAt: new Date(),
missing: undefined,
settings: new Map([["theme", "dark"]]),
};
Después de pasar por un viaje de ida y vuelta en JSON, la instancia Date ahora es solo una cadena y ha perdido todo comportamiento específico de Date. La propiedad undefined desaparece por completo. El Map no sobrevive como Map con sus pares clave-valor intactos, lo cual es un problema si quien llama esperaba un clon estructural adecuado.
Las referencias circulares complican aún más las cosas:
const user = {
name: "Umar",
};
user.self = user;
JSON.stringify(user);
// TypeError
Otros tipos de valores también causan problemas. BigInt no puede pasar por el método ordinario JSON.stringify sin un manejo adicional, las propiedades con claves de tipo símbolo simplemente se omiten, y las instancias de clases personalizadas pierden el prototipo que en un principio les otorgaba su comportamiento específico de clase.
Nada de esto hace que la serialización JSON sea poco fiable; simplemente hace exactamente lo que se supone que debe hacer, es decir, comprimir los valores de JavaScript en la forma más compacta que soporta JSON.
Cuando realmente necesitas un clon estructural, structuredClone() suele ser una mejor opción integrada, siempre y cuando los tipos con los que trabajas estén soportados por esta función. Para datos relacionados directamente con tu dominio, escribir una función de copia explícita puede ser aún más fiable, ya que especifica con exactitud qué propiedades deben conservarse en lugar de asumir que todas las propiedades deben clonarse de forma genérica.
También cabe señalar que el clonado profundo a menudo es la herramienta inadecuada. Si solo necesitas modificar una rama anidada de un objeto, clonar toda la estructura es un desperdicio de esfuerzo y dificulta ver qué parte realmente ha cambiado.
Un buen hábito es nombrar la operación según su verdadera función. Cuando se pretende serializar algo, hacerlo de manera intencionada. Cuando se quiere clonar algo, elegir un método de clonación cuyo comportamiento coincida realmente con los valores que se van a almacenar.
5. Promise.all() no limita cuántas operaciones se ejecutan al mismo tiempo
Imagínese una aplicación que necesita buscar detalles para un grupo de usuarios.
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
Para un pequeño número de usuarios, esto funciona bien y generalmente es la opción adecuada. El código inicia todas las llamadas asíncronas, espera a que se completen todas juntas y devuelve los resultados ordenados de acuerdo con el array de entrada.
Más tarde, alguien reutiliza la misma función con un conjunto de datos de 50,000 registros.
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
Nada en la sintaxis cambió, pero sí el alcance de su funcionamiento. Dado que .map() llama a fetchUser para cada elemento, y cada llamada probablemente envía de inmediato una solicitud a la red o a la base de datos, Promise.all() no impone límite alguno sobre cuántas de esas solicitudes pueden ejecutarse simultáneamente.
Eso puede saturar un servicio posterior, agotar un pool de conexiones, superar los límites de velocidad, elevar drásticamente el uso de memoria o desencadenar una serie de intentos de reintentos una vez que comienzan a acumularse errores. Visualmente, el código sigue pareciendo una operación por lotes ordenada, por lo que es fácil pasar por alto el costo real de su ejecución.
Existe una segunda suposición oculta en Promise.all(): tan pronto como una promesa se rechaza, todo el conjunto también se rechaza. Ese es el comportamiento adecuado cuando se trata al grupo como una sola unidad de trabajo, pero eso no significa que las demás operaciones en curso se cancelen; continúan ejecutándose incluso después de que surja el fallo.
Cuando las tareas son realmente independientes y se desea obtener cada resultado sin importar los fallos individuales, Promise.allSettled() suele ser una opción más adecuada para expresar lo que se pretende lograr.
const results = await Promise.allSettled(
userIds.map((id) => fetchUser(id))
);
Pero si su verdadera preocupación es la carga en otro sistema, cambiar entre Promise.all() y Promise.allSettled() no resolverá ese problema por sí solo, ya que ninguno de los dos limita la concurrencia. Lo que realmente necesita es el agrupamiento de operaciones, un semáforo, una cola o alguna herramienta que limite la concurrencia y mantenga activas solo un número fijo de operaciones a la vez.
Este patrón funciona perfectamente cuando el conjunto de datos de entrada es pequeño y predecible, y el sistema destino puede absorber la carga paralela. El problema surge más tarde, cuando la lógica diseñada para diez registros se aplica a diez mil y nadie se detiene a reconsiderar si eso sigue siendo seguro.
La concurrencia no depende de cuán conciso sea el uso de await con las promesas, sino de cuánto trabajo se inicia y de qué pueden manejar realmente los sistemas subyacentes.
6. Los métodos de array con apariencia funcional aún pueden mutar
Los métodos de array en JavaScript permiten crear pipelines de datos compactos y fáciles de leer, y la mayoría de las veces eso representa una verdadera ventaja.
const activeUsers = users
.filter((user) => user.active)
.map((user) => user.name);
Dado que filter() y map() devuelven arrays nuevos, es tentador pensar que cualquier cadena de métodos de array deja el original intacto. Esa suposición se derrumba en cuanto sort(), reverse(), splice() o algunos otros métodos mutantes se unen a la cadena.
Imagínese un componente de React que recibe un array users como prop:
function UserList({ users }) {
const sortedUsers = users.sort(
(a, b) => a.name.localeCompare(b.name)
);
return sortedUsers.map((user) => (
<User key={user.id} user={user} />
));
}
Parece que este código crea un nuevo array sortedUsers, pero en realidad sort() reorganiza el array original en su lugar y solo devuelve una referencia a ese mismo array. Por lo tanto, un componente cuya única función es renderizar acaba alterando, como efecto secundario, los datos que pertenecen a su componente padre.
Ese tipo de mutación puede permanecer oculta durante mucho tiempo, hasta que otra parte de la aplicación dependa del orden original. Las pruebas para este componente siguen pasando, ya que el resultado renderizado parece correcto, mientras que un componente hermano o un valor memorizado en otra parte comienza a trabajar silenciosamente con datos reorganizados que nunca esperó.
Una solución es clonar el array antes de aplicar un método que lo modifique.
const sortedUsers = [...users].sort(
(a, b) => a.name.localeCompare(b.name)
);
Los entornos de ejecución más recientes de JavaScript también ofrecen versiones no mutantes, como toSorted(), si el entorno de destino las soporta.
La verdadera lección aquí no es memorizar qué métodos de array son “seguros”. Se trata de reflexionar sobre si una operación respeta a quien es dueño de los datos.
Si a una función se le pasa un valor del que no es dueña y lo modifica en silencio, se genera un comportamiento que no tiene nada que ver con lo para lo cual se supone que debe ser responsable. El código es mucho más fácil de comprender cuando la mutación o bien no ocurre en absoluto, o bien ocurre de forma explícita, en el lugar que realmente es dueño del valor.
7. Las tablas de búsqueda pueden ocultar entradas inválidas en lugar de rechazarlas
Es común sustituir una instrucción switch por un objeto simple utilizado como tabla de desvío, ya que se interpreta como algo más declarativo.
const handlers = {
create: createUser,
update: updateUser,
delete: deleteUser,
};
handlers[action]?.(payload);
Esto es compacto, y el desvío mediante búsqueda es un patrón realmente bueno en muchos casos. Lo que es fácil pasar por alto es lo que ocurre cuando action no coincide con ninguna clave conocida.
Dado que este ejemplo utiliza la cadena opcional, una acción no reconocida simplemente no hace nada, en silencio.
action = "udpate"; // typo
handlers[action]?.(payload); // no handler runs
Un simple error de escritura ahora puede pasar por el sistema disfrazado de una operación sin efecto exitosa. A menos que quien llama verifique si existe algún valor de retorno significativo, el resto de la aplicación podría indicar que la operación tuvo éxito aunque en realidad no se haya ejecutado nada.
Mostrar los errores de manera evidente hace que el contrato sea mucho más honesto.
const handler = handlers[action];
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
Las tablas de búsqueda se vuelven más riesgosas cuando las claves provienen de entradas del usuario o fuentes con validación laxa. Los objetos simples también heredan propiedades de su prototipo a menos que se tome la molestia de evitarlo, por lo que tratar cadenas arbitrarias como búsquedas seguras de propiedades requiere más precaución que hacerlo con un conjunto pequeño y cerrado de comandos conocidos internamente.
Usar un Map en su lugar puede hacer que la intención de tipo diccionario sea más clara:
const handlers = new Map([
["create", createUser],
["update", updateUser],
["delete", deleteUser],
]);
const handler = handlers.get(action);
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
En TypeScript, combinar una unión discriminada con un switch exhaustivo puede ser aún más beneficioso, ya que la introducción de una nueva acción obliga al compilador a marcar cada lugar en el código que necesita actualización.
Nada de esto significa que switch sea categóricamente mejor que una tabla de búsqueda. Las tablas de despliegue son útiles cuando las operaciones están impulsadas naturalmente por datos y el comportamiento para claves desconocidas se ha decidido deliberadamente.
Los problemas surgen cuando la indirectitud hace imposible distinguir entre “el programa manejó esto deliberadamente” y “no ocurrió nada en absoluto”.
El verdadero costo de la astucia son las suposiciones ocultas
Ninguno de estos siete hábitos merece una prohibición total.
Las comprobaciones de valor verdadero funcionan bien cuando la condición de veracidad coincide realmente con las reglas de tu dominio. El encadenamiento opcional es ideal para relaciones que son verdaderamente opcionales. La expansión de objetos resulta muy útil para actualizaciones superficiales. JSON es el formato natural para datos con estructura JSON. Promise.all() es perfecto para tareas paralelas con límites definidos. Los métodos de mutación en arrays son aceptables cuando posees el array por completo. Además, las tablas de consulta pueden simplificar enormemente la lógica de envío.
El riesgo radica en tratar sus valores predeterminados convenientes como garantías que la sintaxis realmente ofrece. !value no significa “faltante”. ?. no significa “presente y válido de forma segura”. { ...obj } no significa “copia completamente independiente”. Serializar a JSON y volver a serializarlo no implica “clon genérico”. Y Promise.all() no significa “convergencia controlada”.
Por eso, la versión más fácil de mantener de un fragmento de JavaScript no siempre es la más corta. Unas pocas líneas adicionales que distingan undefined de 0, rechacen una estructura de objeto mal formada, limiten las solicitudes en paralelo o lancen un error ante una acción no reconocida pueden preservar información que, de lo contrario, los futuros mantenedores tendrían que buscar en el historial de commits.
Cada vez que el código le parezca excepcionalmente ingenioso, tómese un momento para observar qué se ha eliminado al comprimirlo. A veces la respuesta es que solo se han quitado los elementos innecesarios mientras el significado permanece intacto, y eso es exactamente lo que debe lograr una buena abstracción. Otras veces, lo que se ha eliminado es alguna distinción que los requisitos actuales aún no han necesitado.
Es este segundo caso el que suele ser la causa de los errores del futuro.
Un hábito útil durante la revisión de código es preguntarse qué condición debe seguir siendo verdadera para que un patrón conciso siga funcionando cuando cambien los requisitos. Si esa condición es importante pero no se ve en ningún lugar del código, escríbala explícitamente mientras la lógica aún esté fresca y sea fácil de seguir.
Los futuros mantenedores rara vez se encuentran con problemas porque JavaScript les permite escribir demasiado código. Se meten en problemas porque el código deja de indicarles silenciosamente qué diferencias son realmente importantes.
Lecturas relacionadas
- Diez hábitos recurrentes de JavaScript que dañan silenciosamente tu código — Explica diez errores comunes en JavaScript y TypeScript, desde la igualdad laxa hasta la mutación de estado, y muestra patrones más seguros para reemplazar cada uno.
- Concepciones erróneas comunes sobre async/await que causan problemas en producción — Explica nueve malentendidos sutiles sobre async/await, desde condiciones de carrera hasta rechazos no manejados, que dañan silenciosamente las aplicaciones JavaScript en el mundo real.