Inicio / Artículos

Diez patrones comunes de JavaScript y las compensaciones detrás de cada uno

Cláusulas de protección, mapas de búsqueda, objetos de resultado, AbortController, allSettled, fábricas y pequeños cachés: cuándo cada patrón es útil y cuándo resulta contraproducente.

Editor: REACTAPP.TOP13 min de lectura2912 palabras

Fuentes: I Reuse These 10 JavaScript Patterns in Almost Every Project

Ya sea que un código base sea un panel de control de React, una API de Node.js o un script de automatización sencillo, eventualmente se enfrenta a las mismas dificultades: las funciones reciben datos incompletos, las respuestas asíncronas llegan fuera de orden, los condicionales se vuelven más complejos debido a cambios en las reglas empresariales, las copias de la misma transformación divergen y el manejo de errores difumina la línea entre “esto falló” y “simplemente no había nada que devolver”. Los frameworks van y vienen, pero estos problemas permanecen notablemente estables. Los diez patrones que se presentan a continuación son respuestas deliberadamente sencillas a ellos. Cada uno reduce el conjunto de estados sobre los que debe razonar una función o hace que sea más fácil identificar quién es el responsable, y para cada uno también aprenderá dónde deja de ser útil, ya que ninguno de ellos reemplaza el juicio crítico.

1. Las cláusulas de protección mantienen el camino principal legible

Cuando el trabajo real de una función se encuentra dentro de varios bloques if anidados, el lector debe mantener en mente una serie de preguntas pendientes: ¿está presente la entrada?, ¿tiene permiso quien llama a la función?, ¿se encontró el registro?, ¿está en un estado que permita este cambio? Cada condición adicional añade un nivel de sangría y algo más que recordar.

Una cláusula de protección responde a cada una de esas preguntas de antemano y abandona el proceso cuando la respuesta es negativa. El ejemplo a continuación rechaza un ID faltante, luego a un usuario no autorizado, después a un artículo ausente y, finalmente, a un artículo que no es borrador; solo después de realizar las cuatro verificaciones realiza la actualización.

async function publishArticle(articleId, currentUser) {
  if (!articleId) {
    throw new Error("Article ID is required");
  }

  if (!currentUser?.permissions.includes("article:publish")) {
    throw new Error("You are not allowed to publish articles");
  }
  const article = await articleRepository.findById(articleId);
  if (!article) {
    throw new Error("Article not found");
  }
  if (article.status !== "draft") {
    throw new Error(`Cannot publish an article in ${article.status} status`);
  }
  return articleRepository.update(articleId, {
    status: "published",
  });
}

La verdadera ventaja no es tener menos sangrías; es la creciente certeza. Después del primer control, el ID existe. Después del segundo, quien llama tiene permisos. Después del tercero, article es un objeto real, por lo que la cadena opcional no necesita extenderse al resto del código. La llamada final a update puede leerse sin que quede nada sin resolver. Hay un pequeño caso límite digno de mención: currentUser?.permissions.includes(...) sigue generando un TypeError si el objeto de usuario llega sin un array permissions, que es exactamente el tipo de inconsistencia que aborda el siguiente patrón.

Los controles se convierten en un problema cuando una función larga está llena de salidas para cada condición menor. Funcionan mejor para las condiciones previas, los estados inválidos, los recursos faltantes y los resultados que ya están completos. Si los controles comienzan a describir varias reglas de negocio distintas, es probable que la función esté tomando demasiadas decisiones y debería dividirse.

2. Normalizar los datos una sola vez, en el límite

JavaScript permite que valores con el mismo significado aparezcan de muchas formas. Una bandera puede ser true, "true", 1 o "1". Un ID puede ser un número en la base de datos y una cadena en la URL. Una fecha puede ser un objeto Date, una cadena ISO, una cadena de formato regional o una marca de tiempo. Si cada función interpreta estos valores por su cuenta, la aplicación acumula gradualmente varias definiciones contradictorias de qué se considera dato válido.

La solución consiste en convertir los valores una sola vez, en el punto en que cruzan un límite significativo, antes de que el resto del código los manipule:

  • Un manejador de API convierte las cadenas a nivel de transporte en los tipos que espera la capa de servicio.
  • Un adaptador de datos mapea una respuesta externa a la estructura interna del proyecto.
  • Un manejador de envío de formularios elimina espacios en las cadenas y resuelve los campos opcionales antes de que se ejecute la lógica empresarial.

El momento en que se realiza la conversión es importante. Cuando esta se pospone, las verificaciones de seguridad se distribuyen por todas partes: una función comprueba status === true, otra verifica status === "true", y una tercera se basa en la propiedad de veracidad. Cada una funciona para su llamante actual, pero el significado de status ya no es estable.

Un contrato interno estricto también hace que las pruebas sean más enfocadas. Cuando la capa de solicitudes ya proporciona un valor booleano real, el servicio no necesita pruebas para cada forma textual posible de ese valor. La capa de normalización sigue necesitando sus propias pruebas, pero todo lo que está detrás de ella se vuelve más simple.

La normalización no es lo mismo que aceptar cualquier cosa. Es apropiada cuando se soportan intencionalmente varias representaciones; los valores ambiguos o inválidos aún deben ser rechazados. Convertir "abc" en NaN y permitir que continúe su flujo no es normalización, sino una validación pospuesta hasta un momento posterior y más confuso.

3. Los objetos Options hacen que las llamadas sean autoexplicativas y más fáciles de extender

Los parámetros posicionales son adecuados para uno o dos valores evidentes. Se vuelven frágiles a medida que la función crece, especialmente cuando varios parámetros comparten el mismo tipo. Una llamada como createUser(name, email, true, false, 3) obliga al lector a consultar la definición para entender el significado de cada posición, y los tipos de TypeScript en la declaración no ayudan en absoluto en el lugar donde se realiza la llamada.

Cuando una función comienza a aceptar varios valores de tipo configuración, generalmente es más claro utilizar un único objeto de opciones:

await createUser({
  name,
  email,
  sendWelcomeEmail: true,
  requireVerification: false,
  retryLimit: 3,
});

La llamada se documenta por sí misma, las propiedades opcionales pueden tener valores predeterminados (típicamente mediante desestructuración en la firma), y agregar una nueva opción no obliga a todos los que llaman a la función a reorganizar sus argumentos. Las funciones de servicio, los auxiliares de configuración, los cargadores de datos y las utilidades en evolución se benefician al máximo.

También elimina ese olor típico de los parámetros booleanos. Un simple true en una llamada casi nunca indica qué comportamiento se activa; en cambio, una propiedad con nombre sí lo hace.

No lo aplique en todas partes. add({ a, b }) no es más claro que add(a, b). Utilice un objeto de opciones cuando los argumentos describen configuraciones, cuando varios son opcionales, cuando múltiples valores comparten el mismo tipo, o cuando la firma del método es propensa a cambiar constantemente.

4. Los mapas de búsqueda convierten las ramificaciones en relaciones explícitas

A menudo aparecen cadenas de instrucciones if o bloques largos de switch cuando un valor selecciona un procesador, formateador, validador o estrategia. Cada nuevo caso alarga el flujo de control y añade otro punto en el que se debe considerar el comportamiento por defecto.

Un mapa de consulta indica directamente la relación: esta clave se asocia a ese comportamiento. Aquí, el estado de un pedido selecciona su manejador, y un estado desconocido genera una error evidente en lugar de pasar desapercibido.

const statusHandlers = {
  pending: handlePendingOrder,
  paid: handlePaidOrder,
  cancelled: handleCancelledOrder,
};

function processOrder(order) {
  const handler = statusHandlers[order.status];
  if (!handler) {
    throw new Error(`Unsupported order status: ${order.status}`);
  }
  return handler(order);
}

Este patrón brilla cuando las ramas son independientes y comparten un mismo contrato: serializadores y formateadores, manejadores de eventos y estados, validadores, políticas de acceso. Soportar un nuevo caso implica agregar una entrada en lugar de extender una condición.

Existe una trampa sutil con los objetos simples. Las propiedades heredadas de Object.prototype, como "toString" o "constructor", también se resuelven como funciones, por lo que un valor de estado inesperado podría seleccionar una de ellas en lugar de activar la protección. Si las propiedades provienen de entrada del usuario, utilice un Map, un objeto creado con Object.create(null) o una verificación con Object.hasOwn.

Evite los mapas de búsqueda cuando la lógica es realmente secuencial. Si una decisión depende de combinaciones de estado, elecciones anteriores o transiciones ordenadas, transformarla en un mapa suele ocultar el flujo en lugar de aclararlo. El mapa funciona cuando una clave selecciona un comportamiento completo y comprensible de forma independiente.

Mantenga la lógica compleja fuera del propio mapa. Las funciones de manejo con nombre generan rastros de pila más claros, son fáciles de probar de forma aislada y permiten que el mapa se enfoque únicamente en la selección.

5. Los objetos de resultado separan los fallos esperados de las ejecuciones fallidas

Las excepciones son la herramienta adecuada para los casos inesperados, pero muchos resultados fallidos son completamente normales. Una búsqueda puede no encontrar nada. Un proceso de registro puede ser rechazado porque la dirección de correo ya está en uso. Una transición de estado puede no estar permitida por las reglas actuales. Si todos estos casos se manejan como excepciones genéricas, quienes los invocan terminan analizando mensajes de error o capturando tipos de error amplios solo para averiguar qué sucedió.

Cuando una operación tiene varios resultados esperados, devolver un pequeño objeto de resultado los incluye como parte del contrato. La función de descuento a continuación devuelve “no encontrado” y “vencido” como datos, cada uno con un code estable y legible por máquinas, y devuelve el pedido ajustado en caso de éxito.

function applyDiscount(order, discount) {
  if (!discount) {
    return {
      ok: false,
      code: "DISCOUNT_NOT_FOUND",
      message: "The requested discount does not exist.",
    };
  }

  if (discount.expiresAt < new Date()) {
    return {
      ok: false,
      code: "DISCOUNT_EXPIRED",
      message: "The discount has expired.",
    };
  }
  return {
    ok: true,
    value: {
      ...order,
      total: order.total - discount.amount,
    },
  };
}

Los llamantes ahora pueden tomar decisiones basadas en ok y code sin necesidad de leer texto dirigido a personas. Los nombres exactos de los campos importan menos que el uso consistente de una misma estructura: algunos proyectos modelan esto como una unión discriminada en TypeScript basada en ok, mientras que otros utilizan success, data y error. Lo importante es que el resultado sea explícito. En una implementación real también habría que decidir qué ocurre cuando el descuento supera el total del pedido, ya que la resta mostrada aquí puede generar un monto negativo.

Los objetos de resultado no sustituyen a las excepciones en todos los casos. Lanza una excepción cuando una dependencia falla de forma inesperada, cuando se viola una invariante o cuando la capa actual no puede hacer nada útil ante el problema. Devuelve resultados cuando el fallo forma parte normal del dominio y se espera que quien llama actúe al respecto.

Una ventaja adicional es la posibilidad de probar: los casos de fallo se especifican en el tipo de retorno, por lo que es fácil verificarlos. Además, un amplio bloque try/catch ya no mezcla una regla de negocio rechazada con una conexión a la base de datos interrumpida.

6. AbortController hace explícita la propiedad asíncrona

Muchos errores en el frontend surgen de tareas asíncronas que se superponen. Un usuario edita un término de búsqueda, se inicia una segunda solicitud y la primera es la última en resolverse. Todas las solicitudes tuvieron éxito, pero la pantalla muestra resultados obsoletos.

La solicitud no es el problema; lo es la propiedad del control. La aplicación debe decidir qué resultado en tiempo real aún permite actualizar el estado. AbortController expresa esa decisión:

  • En un cuadro de búsqueda, abortar la solicitud anterior cada vez que cambie la consulta.
  • En un componente, abortar el trabajo pendiente cuando este sea desmontado.
  • En un flujo de trabajo con pasos dependientes, pasar una señal compartida signal a través de todos ellos para que un único aborto detenga toda la cadena.

Gestiona las cancelaciones intencionadamente. Un aborto generalmente significa que la aplicación decidió que el resultado ya no es relevante, lo cual es diferente de un fallo. Con fetch, una llamada abortada rechaza con un error cuyo name es "AbortError", por lo que puedes reconocerlo explícitamente. Registrar cada aborto como un error inunda tu sistema de monitoreo de ruido, mientras que tratar los verdaderos fallos de red como abortos hace que se pierdan pruebas útiles.

Cuanto más dependa una aplicación de la obtención de datos en segundo plano, la búsqueda en tiempo real, las transiciones de ruta y solicitudes en cadena, mayor es la importancia de esto. Iniciar tareas asíncronas en JavaScript es sencillo; AbortController determina cuándo dichas tareas dejan de pertenecer a quien las inició. Para conocer más sobre el caso de la búsqueda, vea cómo solucionar condiciones de carrera que el retraso en las solicitudes no puede resolver.

7. Promise.allSettled conserva los resultados parciales de tareas independientes

Promise.all es adecuado cuando el resultado combinado no tiene sentido a menos que todas las operaciones tengan éxito. Resulta demasiado agresivo cuando las tareas son independientes: una rechazo descarta todos los valores que sí llegaron.

Considere un panel de control compuesto por cifras de ventas, notificaciones, registros recientes y el estado del sistema, cada uno proveniente de una fuente diferente. Si un servicio opcional falla, dejar el panel de control en blanco suele ser una experiencia peor que mostrar lo que se cargó y marcar lo que no.

Promise.allSettled espera a que se cumplan todas las promesas e informa sobre cada resultado de forma individual, como un objeto con status: "fulfilled" y un value, o status: "rejected" y un reason. Quien llama a la función puede utilizar los valores exitosos, listar los fallos y decidir si la operación en su conjunto sigue teniendo sentido.

No intente usarlo solo para hacer que un lote parezca más robusto. Algunos grupos de promesas, en términos empresariales, representan una sola transacción. Si se trata de crear un pedido, reservar existencias y cargar una tarjeta, todos estos pasos deben tener éxito o fallar juntos; si se recogen resultados parciales, el estado del sistema resultará inconsistente. En ese caso se necesitan acciones compensatorias o una estrategia de orquestación más sólida.

Utilice allSettled cuando las tareas sean realmente independientes y quien las llama cuente con un plan definido para manejar fallos parciales. La verdadera pregunta no es cuál helper de Promise resulta más conveniente, sino si aún es posible obtener una respuesta precisa cuando alguna parte falla. Se abordan más opciones en nueve patrones de Promise para un JavaScript asíncrono fiable en producción.

8. Las funciones de transformación puras facilitan la prueba de reglas de datos

Gran parte del código de las aplicaciones combina la transformación de datos con operaciones de obtención, registro, renderizado, persistencia y actualización de estado. Por lo tanto, probar una regla simple requiere reconstruir todo el entorno alrededor de ella.

Introducir transformaciones estables en funciones puras pequeñas soluciona este problema. Una función pura recibe una entrada, devuelve una salida y no modifica nada fuera de sí misma. Ejemplos típicos incluyen la normalización de registros de API, el cálculo de valores derivados, el agrupamiento de elementos, la creación de objetos de consulta o la conversión de datos del dominio en un modelo de vista.

La ventaja más importante es la existencia de un límite claro entre la obtención de datos y su interpretación. Una función que agrupa registros de empleados no necesita saber cómo se cargaron. Una función que construye el contenido de una solicitud no necesita conocer el estado de los componentes si los valores pueden pasarse como argumentos.

También se vuelve más fácil depurar. Cuando una entrada correcta produce un resultado incorrecto, puedes aplicar la transformación por separado. Si la transformación es correcta, la investigación se dirige hacia lo que generó la entrada o consumió el resultado.

No toda expresión merece tener su propia función; una acumulación de envoltorios de una sola línea dificulta la navegación sin aportar valor real. Extrae funciones cuando la transformación codifique una regla de negocio que reutilices, tenga ramificaciones múltiples o requiera pruebas propias.

9. Las fábricas revelan dependencias y configuración compartida

Una fábrica es una de las formas más sencillas de controlar en qué depende un módulo. En lugar de importar un cliente de base de datos, un registrador, una caché o una configuración desde una ubicación global, la fábrica los recibe como argumentos y devuelve funciones que hacen uso de ellos.

Esto es especialmente útil en servicios de backend y módulos compartidos. Las dependencias se vuelven visibles, disminuye el acoplamiento oculto, y las pruebas pueden utilizar un repositorio o registrador ficticio sin necesidad de simular las importaciones de módulos, mientras que en producción se utilizan los reales. La lógica de negocio se centra en el comportamiento, no en la construcción de sus componentes auxiliares.

Las fábricas también son adecuadas para grupos de funciones que comparten una configuración estable. Una fábrica de clientes HTTP puede capturar una URL base, un token de autenticación, un tiempo de espera y un registrador una sola vez, y exponer métodos que los utilicen todos, con cierres que almacenen la configuración para que no se transmita en cada llamada.

El riesgo radica en crear un marco propio de inyección de dependencias cuando la base de código ya cuenta con uno, o cuando una simple importación sería suficiente. Utilice fábricas cuando las dependencias cambien significativamente el comportamiento, cuando las pruebas necesiten control sobre ellas, o cuando varias instancias con configuraciones diferentes deban coexistir. El objetivo es revelar las dependencias, no crear otro marco más para mantener.

10. Los cachés Narrow Map y WeakMap evitan el trabajo repetido

Map y WeakMap son buenos cachés locales cuando la misma computación costosa o los metadatos específicos de un objeto tendrían que volverse a calcular. Un analizador puede memorizar los resultados por cada cadena de entrada. Un formateador puede reutilizar datos derivados costosos. Un WeakMap, cuyas claves deben ser objetos, puede adjuntar metadatos a esos objetos sin mantenerlos activos una vez que nada más los referencia.

Escribir en la caché es la parte fácil. Determinar si un valor almacenado en caché sigue siendo válido es lo difícil.

Los problemas con las cachés ocurren de maneras predecibles:

  • La clave omite un parámetro que afecta el resultado, por lo que una caché que parece correcta en las pruebas proporciona valores obsoletos en producción.
  • Los datos subyacentes cambian y nada invalida la entrada en caché.
  • Su crecimiento es ilimitado, al igual que ocurre con un Map a nivel de módulo que permanece activo durante toda la vida útil de un proceso del servidor.

Antes de agregar algo a la caché, identifique un costo real y recurrente y defina quién es el responsable, la estructura de la clave, su duración de vida y las reglas para invalidarlo. A menudo, volver a calcular es más seguro y lo suficientemente rápido. La caché solo tiene sentido cuando el proceso es costoso, los datos de entrada son estables y su alcance puede mantenerse limitado.

Este patrón ilustra bien toda la lista. Map hace que el caché sea sencillo de implementar, pero lo fácil no es lo mismo que lo correcto: la mejora en velocidad solo es real si se puede explicar cuándo una entrada deja de ser válida.

Los patrones deben eliminar las decisiones, no ocultarlas

En conjunto, estos patrones se ganan su lugar al eliminar la ambigüedad en lugar de destacar características del lenguaje. Los protectores descartan las rutas inválidas de inmediato; la normalización en los bordes proporciona valores predecibles al código interno; los objetos de opciones mantienen las llamadas legibles y abiertas a extensiones; los mapas de búsqueda exponen relaciones directas; los objetos de resultado mantienen los resultados significativos; AbortController asigna la responsabilidad al trabajo asíncrono; allSettled informa con honestidad sobre fallos parciales; las funciones puras aíslan las decisiones de los efectos secundarios; las fábricas hacen visibles las dependencias; y los pequeños cachés evitan el trabajo repetido cuando su validez está clara.

Cada uno de ellos también puede ser aplicado incorrectamente. Envolver una llamada con dos argumentos en un objeto de opciones es pura formalidad; devolver resultados desde código que debería lanzar excepciones lo complica; un mapa puede ocultar una lógica ordenada; una caché puede volverse obsoleta; y una fábrica puede ser una abstracción de la que nadie necesitaba. Un patrón se vuelve perjudicial cuando se elige por su apariencia pulida en lugar de por la verdadera presión que alivia.

Puntos clave

  • Juzgue un patrón según el equilibrio que establece, no por lo sofisticado que parezca.
  • Preferir estructuras cuyos costos sean visibles y cuyos beneficios perduren tras los cambios posteriores.
  • Los buenos patrones hacen explícitas las suposiciones, responsabilidades, modos de fallo y rutas válidas de una función.
  • Los mejores se integran en segundo plano: los lectores notan que el problema se simplifica, no el patrón en sí.

Lecturas relacionadas