Inicio / Artículos / Diez reglas cotidianas de JavaScript que alteran tu modelo mental

Diez reglas cotidianas de JavaScript que alteran tu modelo mental

Examina diez comportamientos sutiles de JavaScript, desde la mutabilidad de const hasta los cierres y el manejo asincrónico de errores, que causan errores silenciosamente en el código de los desarrolladores experimentados.

4076 palabras

JavaScript deja de parecer intimidante una vez que te familiarizas con su sintaxis. Dejas de tropezar por la falta de puntos y coma, las llamadas asincrónicas se vuelven algo habitual, y la diferencia entre let y const se vuelve algo natural. Después de desarrollar suficientes proyectos, el lenguaje comienza a sentirse como un viejo amigo. Puedes identificar los errores comunes al instante, tienes una buena intuición sobre cómo se resuelven las promesas, y sabes que null y undefined no son lo mismo, por más que algunas APIs los confundan a la ligera.

Pero la familiaridad no elimina todas las sorpresas, solo cambia su forma. La confusión de nivel principiante es reemplazada por suposiciones más sutiles y peligrosas. Sabes que los objetos se pasan por referencia, pero aún olvidas que una copia superficial deja los datos anidados compartidos. Sabes que las promesas dependen de microtareas, pero aún juzgas mal el orden cuando varias colas comienzan a interactuar. Sabes que existe la coerción, pero aún pasas por alto la única conversión silenciosa oculta dentro de una comparación, llamada a sort o búsqueda de propiedad de apariencia inocente.

Las partes más complicadas de JavaScript rara vez son casos extremos exóticos o curiosidades del lenguaje. Se trata de reglas comunes que se combinan de maneras inesperadas. Cada línea por separado parece correcta, es fácil de leer y pasaría una revisión casual. La sorpresa surge de cómo el entorno de ejecución une esas líneas, de una forma que no coincide con el modelo mental que tenías al leer el código.

Los diez comportamientos que se detallan a continuación siguen causando problemas a los equipos experimentados precisamente porque se ocultan dentro de código que parece perfectamente normal.

1. const protege el enlace, no el objeto

Una forma abreviada común para explicar const es decir que “crea un valor que no puede cambiarse”. Esa es una buena simplificación para los tipos primitivos, pero deja de ser válida con arrays y objetos. Lo que realmente garantiza const es que la variable no pueda ser reasignada para apuntar a otro elemento. No dice nada sobre si lo que ella apunta puede ser modificado.

Un objeto user declarado con const aún puede adquirir nuevas propiedades. Su objeto de configuraciones anidado también puede actualizarse. Un array declarado con const aún se puede modificar añadiendo elementos, ordenar o vaciar. Desde la perspectiva de JavaScript, nada de eso afecta al vínculo; la variable sigue refiriéndose exactamente al mismo objeto que siempre ha hecho.

Esta brecha entre las expectativas y la realidad sigue generando errores reales en bases de código que dependen en gran medida de la idea de inmutabilidad. Un objeto de configuración se importa como una constante, luego un módulo lo modifica silenciosamente, y de repente todos los demás módulos que comparten esa referencia ven el cambio. Un array almacenado en el estado de la aplicación se ordena in situ, mutando tanto el valor “actual” como uno anterior que otra parte del código asumía era una instantánea histórica congelada. Una prueba modifica un objeto de fixture compartido, y una prueba completamente ajena comienza a fallar, pero solo cuando el ejecutor de pruebas decide ejecutar las pruebas en un orden diferente.

La palabra clave const brinda una falsa sensación de seguridad; parece una capa protectora alrededor del valor, pero en tiempo de ejecución solo protege el puntero a la variable, nunca la estructura a la que apunta.

Si realmente necesitas inmutabilidad, debes crearla tú mismo. Eso podría significar devolver objetos completamente nuevos en lugar de editar los existentes, aplicar Object.freeze a los valores específicos que son importantes, adoptar una biblioteca diseñada para estados inmutables, o diseñar tus funciones de tal manera que nunca entreguen referencias a datos internos mutables desde un principio. Incluso Object.freeze solo bloquea el nivel superior; a menos que también congeles cada objeto anidado, las capas interiores siguen siendo tan mutables como antes.

La mayoría de los desarrolladores experimentados pueden recitar esta regla de memoria, pero la sorpresa vuelve a surgir cada vez que el estilo visual del código implica garantías más estrictas de las que JavaScript realmente ofrece.

2. Las copias por propagación de objetos son menos efectivas de lo que parecen

Difundir un objeto con { ...obj } se ha convertido en uno de los modismos habituales de JavaScript. Se lee de manera clara, es ideal para combinar valores por defecto con sobrescrituras, y permite generar una versión modificada de un valor sin tocar el original.

Visualmente, parece un duplicado completo e independiente. En realidad, la copia solo llega a un nivel de profundidad.

const original = {
  profile: {
    name: "Umar",
    skills: ["JavaScript", "Node.js"],
  },
};

const copy = { ...original };
copy.profile.skills.push("TypeScript");

Una vez que se ejecuta este fragmento, la habilidad recién añadida también aparece dentro de original.profile.skills. El objeto de nivel superior es, en efecto, nuevo, pero el objeto profile anidado dentro de él, y el array skills anidado aún más adentro, siguen siendo exactamente los mismos objetos a los que apuntaba el original.

Eso es lo que hace que este error sea tan persistente: la primera capa se comporta exactamente como uno esperaría. Al verificar copy !== original se obtiene true, lo cual parece ser prueba de que se ha logrado una separación clara. El problema de mutación compartida solo se manifiesta cuando se analiza un nivel más profundo.

La propagación también genera una segunda sorpresa cuando se utiliza para fusionar objetos de configuración. Al escribir { ...defaults, ...options } se reemplazan objetos anidados completos en lugar de fusionar sus claves individuales. Si options sobrescribe un valor dentro de un objeto anidado, todos los valores hermanos que estaban junto a él en defaults desaparecen también, aunque quien llamó a la función nunca tuvo intención de modificarlos.

La solución no consiste automáticamente en “clonar todo en profundidad”. Un clon completo en profundidad puede ser costoso, eliminar la identidad de los objetos de los que realmente dependes en otras partes y duplicar datos que deberían permanecer compartidos. Lo importante es determinar qué rutas anidadas específicas necesitan ser copias independientes. Trátalas explícitamente, utiliza una herramienta adecuada para actualizaciones inmutables o reestructura los datos profundamente anidados de modo que los límites de propiedad sean evidentes a simple vista.

La expansión de objetos funciona tal como se anuncia cuando lo que realmente deseas es una copia superficial. Se convierte en un problema en el momento en que su sintaxis concisa se confunde con un clon completo en profundidad.

3. La igualdad ordinaria puede ocultar varias conversiones

Los desarrolladores más experimentados optan por === específicamente para evitar los problemas de coerción asociados con ==. Esa costumbre es realmente útil, pero no los protege de todas las conversiones implícitas que realiza JavaScript; muchas otras ocurren en lugares que no tienen nada que ver con el operador de igualdad.

Las claves de las propiedades de un objeto son un buen ejemplo. Con la excepción de los símbolos, toda clave de objeto es, en realidad, una cadena de texto. Por lo tanto, al establecer object[1] y luego leer object["1"], ambos acceden a la misma propiedad. El código que trata mentalmente los identificadores numéricos y de cadena como dos mundos separados puede quedar desprevenido en este caso.

Las comparaciones relacionales realizan sus propias conversiones según lo que se compare. Dos cadenas se comparan de forma léxica (carácter por carácter), mientras que al comparar un número con una cadena numérica puede producirse en su lugar una comparación numérica. Por eso "20" < "100" da como resultado false, mientras que 20 < "100" da como resultado true. Al cambiar la fuente de los valores, la lógica de ordenamiento o validación puede modificar el comportamiento, aun cuando los valores mostrados parezcan idénticos.

El operador + es especialmente complicado porque funciona tanto como suma numérica como concatenación de cadenas, y JavaScript decide cuál utilizar según el contexto. Una sola cadena que aparece al principio de una secuencia de sumas puede cambiar la interpretación de todo lo que sigue. Dado que los valores obtenidos de los campos de formulario, los parámetros de consulta de URL y otras fuentes HTML suelen llegar como cadenas, una expresión que funcionaba perfectamente con valores numéricos internos puede pasar silenciosamente a la concatenación de cadenas en el momento en que se conecte con entradas dirigidas al usuario.

La forma en que los equipos experimentados evitan estas trampas es normalizando los valores directamente en el límite, en lugar de confiar en que los operadores interpreten la entrada bruta correctamente sobre la marcha. Un identificador se define una vez, desde el principio, como cadena o número. Una cantidad monetaria se convierte en un tipo numérico validado. Una fecha se analiza para convertirla en un tipo temporal adecuado o en una cadena ISO fija antes de que se realicen cualquier comparación.

La coerción en sí no es el verdadero problema; las reglas de JavaScript al respecto están bien definidas y son consistentes. El verdadero problema es que esas reglas siguen activándose en lugares donde nada en el código indica visualmente que se está realizando una conversión.

4. Array.prototype.sort Reordena en el mismo lugar y compara texto por defecto

La ordenación parece ser una de las operaciones más sencillas disponibles en un array, pero en realidad combina dos comportamientos que suelen confundir a las personas.

La primera sorpresa es que modifica el array. Al llamar a .sort() se reordena el array al que se le aplica la función y se devuelve una referencia a ese mismo array. Es fácil almacenar el valor devuelto en una variable nueva y asumir que el array original mantiene su orden inicial, solo para descubrir que ambos nombres apuntan ahora a la misma lista reordenada.

La segunda sorpresa está relacionada con cómo funcionan las comparaciones cuando no se proporciona un comparador. En ese caso, JavaScript convierte cada elemento en una cadena de texto y los ordena léxicamente. Si se ejecuta esta operación con un array de números, es posible obtener resultados como 1, 100, 20, 3 en lugar del orden numérico ascendente.

Esta combinación se vuelve peligrosa en la gestión del estado del frontend. Imagine un componente que ordena un array justo antes de renderizar, sin darse cuenta de que ese array es una referencia compartida a datos almacenados en caché o proporcionados por el servidor. Esto provoca que se modifique esa fuente compartida. Un componente hermano que lee los mismos datos subyacentes también verá el nuevo orden, sin haber llamado nunca a la función sort. La lógica de detección de cambios y la memorización también pueden fallar en este caso, ya que la identidad del array (su referencia) nunca cambió aunque su contenido sí lo hizo; por lo tanto, una comparación superficial no indicará que algo sea diferente.

JavaScript moderno ofrece alternativas que no modifican los datos: toSorted() está disponible en entornos que lo soportan, y clonar el array antes de ordenarlo sigue siendo la solución estándar en aquellos donde no está disponible. Para el ordenamiento numérico, aún es necesario pasarle a .sort() un comparador explícito que codifique la comparación que realmente se desea realizar.

La conclusión general es que el nombre de un método no revela su funcionalidad completa. “Ordenar” describe el resultado final, pero no indica si modifica los datos en su lugar, cómo convierte los valores para la comparación, qué garantías de estabilidad ofrece ni qué tipo de ordenamiento específico del dominio podría ser necesario. Incluso los desarrolladores experimentados cometen errores cuando un método les parece lo suficientemente rutinario como para no detenerse a reconsiderar qué hace realmente en el fondo.

5. Un objeto Date modela un instante único: la mayoría de las entradas no se corresponden perfectamente con uno

Los errores relacionados con las zonas horarias en JavaScript no se deben realmente a que las zonas horarias sean conceptualmente complejas. Se deben a que una cadena corta contiene muchas más suposiciones implícitas de las que parece.

En realidad, un objeto Date es una marca de tiempo: un instante preciso medido con respecto a UTC. Pero la mayoría de las fechas con las que realmente trabajan las personas —un cumpleaños, una fecha de vencimiento, un ciclo de facturación, una reunión programada— son conceptos calendáricos, no puntos fijos en el tiempo universal, y cada uno se relaciona con las zonas horarias de manera diferente.

Si se analiza una cadena que contiene solo una fecha y luego se muestra utilizando la hora local, la fecha en el calendario que ve el usuario puede cambiar según su ubicación. Un valor destinado a representar “3 de agosto” podría interpretarse como la medianoche en UTC, lo que al mostrarse localmente en otro lugar se vería como el 2 de agosto. De manera similar, una marca de tiempo creada sin un desfase UTC explícito puede interpretarse de forma diferente dependiendo del formato exacto de la cadena y del proceso de análisis en tiempo de ejecución.

El horario de verano añade otra complicación. Añadir un número fijo de milisegundos a una Date no garantiza que sea lo mismo que añadir un día calendario en la hora local; algunos días, en las zonas donde se aplica el cambio de horario, tienen en realidad veintitrés o veinticinco horas de duración.

Los desarrolladores experimentados siguen tropezando con esto porque su código depende de un único tipo Date para representar varias ideas no relacionadas al mismo tiempo. El sistema de tipos no indica si un valor determinado debe ser un instante preciso, una fecha simple del calendario o una hora local destinada a su interpretación en una región específica.

Los sistemas robustos resuelven esto al hacer explícita la intención en lugar de dejarla implícita. Los instantes incluyen un desfase UTC o se expresan directamente en UTC. Los valores exclusivamente de calendario permanecen como tales, sin ser elevados innecesariamente a marcas de tiempo completas. Los horarios específicos por región conservan el contexto de zona horaria con el que fueron creados. El análisis y el formato se realizan en límites claramente definidos dentro del sistema, y no de forma dispersa dondequiera que la fecha aparezca o sea leída.

Por lo tanto, la sorpresa generalmente no radica en que estén involucrados los husos horarios, sino en que alguna conversión de apariencia inocua ya haya tomado una decisión silenciosa sobre cuál utilizar.

6. Una promesa puede resolverse antes de que se ejecute realmente su función de callback

Una promesa resuelta parece un asunto ya concluido, pero la función asociada a ella mediante .then — o el código que sigue a un await — no se ejecuta en medio del código síncrono que se está procesando en ese momento. En lugar de eso, se coloca en la cola de microtareas y se ejecuta posteriormente.

Esa cola de ejecución genera un orden que aún puede tomar por sorpresa a los desarrolladores experimentados cuando las promesas, los temporizadores, los manejadores de eventos y el código síncrono tradicional comienzan a mezclarse. Una promesa que ya se ha resuelto programa su continuación para más tarde, mientras que el código síncrono en ejecución continúa sin interrupciones. Esa continuación en cola generalmente se ejecutará antes de que se active cualquier función de callback de temporizador programada para la siguiente tarea, incluso uno de setTimeout con un retraso de cero milisegundos.

El verdadero peligro práctico no radica en adivinar el orden de los registros en la consola durante una prueba. Se trata de comprender que “la promesa se ha resuelto” y “su manejador realmente se ha ejecutado” son dos momentos distintos en el tiempo. Una actualización de estado realizada a través de un manejador de promesas podría no ser visible aún para el código que se ejecuta más tarde en esa misma pila síncrona. Una prueba podría verificar un valor antes de que las microtareas pendientes hayan tenido la oportunidad de procesarse. Además, una cadena lo suficientemente larga de microtareas enlazadas puede retrasar los temporizadores y el proceso de renderizado más de lo esperado, ya que el entorno de ejecución siempre agota toda la cola de microtareas antes de pasar a cualquier otra cosa.

El código se vuelve frágil cada vez que depende de este orden incidental en lugar de de una secuenciación explícita. Por eso, los desarrolladores experimentados prefieren devolver y esperar promesas cuando realmente un paso debe seguir a otro, se apoyan en los ganchos de ciclo de vida que ofrece su framework y evitan tratar un temporizador sin demora como una forma fiable de sincronizarse con otras tareas.

El bucle de eventos en sí mismo se comporta de manera consistente; es la sintaxis la que hace que varios cronogramas separados parezcan estar más cerca de lo que realmente están. Una promesa ya puede contener su valor final mientras el resto de la aplicación aún no se ha dado cuenta de ello.

7. Envolver una llamada async en try/catch no garantiza que se capture algo

Un bloque try/catch alrededor de una llamada a función parece proteger esa llamada de los fallos. Con las funciones asíncronas, el hecho de que esa protección funcione realmente depende por completo de si se espera la promesa devuelta.

try {
  saveAuditLog(record);
} catch (error) {
  reportError(error);
}

Si saveAuditLog lanza un error de forma síncrona antes de devolver una promesa, el bloque catch lo manejará sin problemas. Pero si saveAuditLog es una función asíncrona que falla más tarde, para cuando rechaza la operación ya ha devuelto una promesa y la ejecución ya ha salido del bloque try. El rechazo ahora está en esa promesa; no tiene nada que ver con la llamada síncrona que ya finalizó.

Agregar await vuelve a vincular el rechazo con el bloque try/catch que lo contiene, pero solo funciona si la función que se está escribiendo permite esperar algo. Redirigir la promesa a otra cadena también puede mantener esa relación de error. Simplemente llamar a una función asíncrona desde dentro de código de manejo de errores con sangría, sin esperarla ni devolverla, no logra nada de esto.

Este error aparece constantemente en los manejadores de eventos, las funciones de callback de arrays y los ganchos de bibliotecas, donde la API circundante a menudo no sabe qué hacer con una promesa devuelta por un callback asíncrono, y frecuentemente simplemente la ignora. El callback sigue rechazándose correctamente, pero no hay nada que lo detecte. Dependiendo del entorno de ejecución, esto puede manifestarse como una advertencia de rechazo no manejado, un error registrado, un proceso que se cae o tareas que silenciosamente nunca son reconocidas.

Los desarrolladores que han sufrido las consecuencias de estos fallos debido al uso de promesas en lugar de sangría de código. Preguntan qué ámbito está realmente esperando el trabajo asíncrono, dónde se convierte el rechazo en algo accionable, y si existen llamadas realizadas intencionalmente sin seguir un proceso específico que aún tengan una vía real para reportar el fallo.

Los errores asíncronos se propagan a lo largo de las cadenas de promesas, no según el nivel de anidamiento de los paréntesis.

8. Los valores por defecto en la desestructuración solo se aplican a undefined

Los valores por defecto durante la desestructuración parecen ser una medida segura contra la falta de datos de entrada.

const { timeout = 5000 } = options;

Ese valor por defecto solo se aplica cuando timeout es undefined o simplemente está ausente del objeto. No se aplicará para null, 0, una cadena vacía, ni ningún otro valor que haya sido proporcionado explícitamente.

A menudo ese es exactamente el comportamiento que se desea. Cero podría ser una forma intencionada de desactivar un retraso, y null podría tener un significado propio distinto. Los problemas comienzan cuando alguien asume que el valor por defecto sirve como solución general para todo lo que es “inviable”. Una API podría devolver null, y el código posterior intentaría realizar operaciones matemáticas con él. Un campo de formulario podría enviarse como una cadena vacía, por lo que el valor por defecto nunca se activa. Un cargador de configuraciones podría distinguir cuidadosamente entre “la variable no está definida” y “la variable está definida explícitamente como vacía”, mientras que el código que consume esa configuración trata ambos casos de manera idéntica y recurre al valor por defecto.

Los parámetros predeterminados de las funciones siguen la misma lógica: al llamar a una función sin argumentos se aplica el valor predeterminado, pero si se pasa explícitamente null, este no se utiliza. Esta distinción es de gran importancia cuando los datos se transmiten a través de cargas JSON, filas de base de datos, envíos de formularios y APIs de terceros, lugares donde null aparece con frecuencia.

Los desarrolladores que dependen de este patrón suelen mantener la definición de valores predeterminados y la validación como aspectos separados. Un valor predeterminado responde a "¿qué ocurre cuando no se proporciona nada?". La validación responde a "¿es lo que se proporcionó realmente aceptable?". Combinar ambas tareas en una sola sintaxis conveniente puede hacer que los datos defectuosos parezcan estar correctamente inicializados.

La regla de este lenguaje es precisa y consistente. La confusión surge únicamente por la palabra “default”, que sugiere un alcance más amplio del que en realidad ofrece el comportamiento estricto basado únicamente en undefined.

9. Una propiedad faltante, una propiedad eliminada y undefined son tres cosas diferentes

JavaScript permite que una propiedad de un objeto tenga el valor undefined, o simplemente no exista en absoluto en el objeto. Al leer cualquiera de ellas directamente se obtiene undefined, por lo que parecen intercambiables, pero no lo son.

Herramientas como el operador in, Object.hasOwn, Object.keys, la sintaxis de expansión, la iteración, los validadores de esquema y la serialización JSON pueden ayudar a distinguir estas diferencias. Por ejemplo, JSON.stringify elimina por completo las propiedades cuyo valor es undefined, mientras que un elemento de array que contiene undefined se trata de manera diferente. La fusión de objetos también puede sobrescribir un valor existente perfectamente válido con undefined, incluso cuando quien realiza la fusión solo pretendía dejar ese campo sin cambios.

Esta laguna es una fuente frecuente de errores sutiles al actualizar datos. Un payload PATCH en el backend podría interpretar un campo omitido como “dejarlo tal cual” y un campo establecido explícitamente en null como “eliminarlo”. Mientras tanto, un formulario en el frontend podría generar undefined para cualquier campo que el usuario no haya modificado; sin embargo, al incluir esos datos del formulario en un objeto de actualización, se siguen insertando esas propiedades undefined, las cuales sobrescriben los valores existentes durante la fusión.

Para evitar esto, defina intencionalmente las semánticas de actualización en lugar de que surjan por casualidad. La omisión, undefined, null y valores verdaderamente vacíos no deberían adquirir un significado simplemente debido al mecanismo de serialización o fusión de objetos que se esté utilizando.

Esto es especialmente importante en TypeScript, donde una propiedad opcional y una propiedad obligatoria cuyo tipo incluye por casualidad undefined expresan dos intenciones estructuralmente diferentes, incluso si el código de la aplicación que las rodea termina tratándolas de la misma manera más adelante. Las configuraciones más estrictas del compilador pueden hacer valer esa distinción a nivel de tipo, pero los datos reales que fluyen por la aplicación en tiempo de ejecución siguen necesitando su propia validación.

Dos valores que parecen idénticos al leerlos pueden seguir representando contratos muy diferentes una vez que atraviesan un límite de red o mediante una operación de actualización.

10. Un cierre mantiene una variable, no una instantánea congelada en el tiempo

Las cierres son una de las herramientas más poderosas que JavaScript pone a tu disposición. Una función mantiene el acceso a las variables del ámbito en el que fue definida, y eso es lo que hace que los callbacks, las funciones de fábrica, los manejadores de eventos y los patrones de módulos funcionen con tanta naturalidad.

La forma abreviada común que usan las personas es decir que un cierre “recuerda un valor”. Una descripción más precisa es que mantiene el acceso al enlace de la variable. Si el valor de esa variable cambia antes de que el cierre se ejecute, este verá el nuevo valor, no el que existía cuando fue creado.

Esta es la explicación clásica detrás de los errores en bucles que involucran a var, pero los desarrolladores con más experiencia suelen encontrarse con versiones más sutiles del mismo problema en código asíncrono y lógica de interfaz de usuario. Un callback podría leer un objeto de configuración que cambió después de que comenzó la operación. Un manejador de eventos podría hacer referencia a un estado que ya está desactualizado. Una función diferida podría operar sobre el estado actual de un objeto mutable, cuando el desarrollador asumió que utilizaría el objeto tal como se veía en el momento en que se programó la función.

Los frameworks añaden sus propias reglas de ciclo de vida encima de esto, lo que tiende a hacer que el efecto sea más evidente. En React, por ejemplo, una función de callback creada durante un renderizado específico mantiene referencias a los valores que existían en ese renderizado concreto. Eso puede generar un comportamiento similar al de estado obsoleto, aunque la cierre en JavaScript subyacente funcione exactamente como está diseñado. La sorpresa surge al esperar que la función de callback alcance de alguna manera el estado más reciente automáticamente, algo que no es cómo funcionan las cierres.

La solución adecuada depende de lo que realmente necesites. A veces deseas genuinamente el valor tal como existía cuando se inició la operación; en ese caso, capturar una instantánea estable de antemano es la opción correcta. Otras veces quieres el valor más reciente disponible, lo que requiere una referencia actual similar a una variable de referencia o una actualización funcional. Y en ocasiones, la solución adecuada es que las dependencias variables reconstruyan por completo el callback.

La pregunta útil que debes hacerte es si el trabajo diferido debe reflejar el estado del mundo en el momento en que se programó, o el estado del mundo en el momento en que realmente se ejecuta. El cierre en sí no tiene opinión al respecto: preserva fielmente cualquier relación que tu código haya establecido, incluso cuando esa relación no sea la que pretendías crear.

JavaScript se comporta de manera consistente: nuestros atajos mentales, no.

Ninguno de los comportamientos aquí descritos son peculiaridades arbitrarias. const bloquea un enlace, no el valor que contiene. La operación de expansión crea copias solo a un nivel de profundidad. El ordenamiento por defecto de los arrays convierte primero los elementos en cadenas de texto. Los manejadores de Promise se ejecutan como microtareas. La desestructuración responde específicamente a undefined. Las clausuras conservan los enlaces léxicos en lugar de valores fijos. Cada una de estas reglas es aplicada por el lenguaje con total coherencia.

Las sorpresas surgen cuando los desarrolladores se basan en modelos mentales más simples de lo que realmente impone el entorno de ejecución. Decimos que una constante “no puede cambiarse”, que la expansión “crea una copia”, que una llamada asíncrona “está dentro de try/catch” o que una clausura “recuerda un valor”. Esas simplificaciones son útiles hasta que algún nuevo requisito depende precisamente de los detalles que omitieron.

Lo que protege a los desarrolladores experimentados no es memorizar una lista cada vez más larga de datos triviales, sino el hábito de hacer explícitos los acuerdos siempre que los datos cruzan una frontera. Eso implica normalizar los valores provenientes del mundo exterior, tratar “ausente” e “inválido” como conceptos separados, evitar la mutación accidental de referencias compartidas, ser claros sobre quién se encarga del manejo de errores en una operación asíncrona, preservar el significado pretendido de las marcas de tiempo y las zonas horarias, y decidir de antemano si una llamada de retorno diferida debe actuar sobre el estado anterior o el actual.

Escribir JavaScript fiable no se trata de evitar todas las características flexibles que ofrece el lenguaje. Se trata de utilizar esas características sin asumir que su sintaxis compacta promete más de lo que realmente entrega.

JavaScript sigue sorprendiendo a los desarrolladores experimentados precisamente porque la experiencia genera confianza en el código que parece familiar. El riesgo es que una sintaxis de apariencia conocida pueda seguir ocultando decisiones relacionadas con referencias, conversiones de tipo, tiempos de ejecución y responsabilidad por errores; decisiones que solo se vuelven visibles cuando algo más en el sistema cambia alrededor de ellas.

Casi siempre, el lenguaje hace exactamente lo que se le ha indicado hacer.

La sorpresa surge al darse cuenta de lo que en realidad se le ha ordenado hacer a su código.

Lecturas relacionadas

  • Diez hábitos recurrentes de JavaScript que minan silenciosamente tu codificación — Explica diez problemas 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.