wandres.dev
MÓDULOS JS · ESM vs CommonJS

ESM: import, export y bindings vivos

El sistema de módulos oficial de JavaScript: la sintaxis import/export, el análisis estático que lo distingue, los bindings vivos frente a las copias, y por qué todo eso habilita el tree shaking.

⏱ 15 min

ESM (ECMAScript Modules) no es una convención más: es la única forma de módulo especificada dentro del lenguaje, presente desde ES2015. Su rasgo definitorio no es la sintaxis, sino su estructura estática — la propiedad de la que cuelgan el tree shaking, los bindings vivos y todo el tooling moderno de 2026.

🎯 Al terminar esta lección sabrás
  • Dominar la sintaxis completa: named, default, namespace y re-exports.
  • Entender por qué ESM es analizable estáticamente y qué se gana.
  • Interiorizar los bindings vivos: import referencia, no copia.
  • Conectar el análisis estático con el tree shaking real.

La forma canónica de importar y exportar

export marca lo que un módulo ofrece; import declara lo que consume. Hay exportaciones con nombre (cuantas quieras) y una default (como mucho una):

// mates.ts
export const PI = 3.14159;                 // named export
export function sumar(a: number, b: number) { return a + b; }
export class Vector { /* ... */ }

export default function crear() { /* ... */ }   // default: una por módulo

// re-exportar sin traer el símbolo al ámbito local
export { sumar as add } from "./mates.ts";
export * as geometria from "./geometria.ts";

Del lado consumidor, las cuatro variantes que cubren todo:

import crear, { PI, sumar } from "./mates.ts";  // default + named
import * as mates from "./mates.ts";            // namespace (objeto sellado)
import { sumar as add } from "./mates.ts";      // renombrar en destino
import "./efectos.ts";                           // solo por su efecto lateral

Un matiz de diseño con consecuencias reales: en librerías se prefieren las exportaciones con nombre sobre la default, porque un nombre estable sobrevive mejor al renombrado, alimenta el autocompletado y es más fácil de rastrear para el tree shaking. La default brilla cuando un módulo representa una sola cosa —un componente, una clase—; abusar de ella dispersa la superficie de la API.

En 2026 los import attributes ya son estándar: import datos from "./data.json" with { type: "json" } carga JSON de forma nativa en Node y navegadores, sin loaders intermedios.

Las posiciones donde ESM admite estas formas están fijadas por la gramática, y esa rigidez es deliberada:

Forma Qué hace Estatus
import { x } from "m" binding con nombre estático
import x from "m" binding por defecto estático
import * as ns from "m" namespace sellado estático
export { x } from "m" re-export directo estático
import("m") carga bajo demanda frontera de chunk

Solo la última rompe la rigidez, y lo hace en una frontera explícita (lección 2.3). Todo lo demás vive en el nivel superior del módulo, sin condicionales ni rutas calculadas.

El módulo como grafo estático

La clave de ESM es que import/export son formas sintácticas que solo pueden aparecer en el nivel superior del módulo: no dentro de un if, ni con una ruta calculada en tiempo de ejecución. Eso permite conocer el grafo de dependencias antes de ejecutar una sola línea. El motor procesa cada módulo en tres fases:

flowchart LR
P[parse y resolver imports] --> I[instanciar y enlazar bindings]
I --> E[evaluar de arriba abajo]
style P fill:#89b4fa,color:#11111b
style I fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Primero se parsea y se descubren las dependencias; luego se instancia (se reserva el entorno del módulo y se cablean los bindings entre importador y exportador, aún sin valores); por último se evalúa en orden. Esta separación entre enlazado y evaluación es lo que hace posible tanto los bindings vivos como las importaciones circulares controladas.

Los ciclos ilustran por qué importa esa separación. Si a.ts importa de b.ts y viceversa, la instanciación cablea ambos bindings antes de evaluar nada; durante la evaluación, leer un binding cuyo módulo aún no lo asignó cae en su zona muerta temporal (TDZ) y lanza. ESM no prohíbe los ciclos: los vuelve deterministas, a diferencia de CommonJS, donde un ciclo devuelve un module.exports a medio poblar sin avisar.

Bindings vivos: una ventana, no una foto

Un import con nombre no copia el valor: crea una referencia de solo lectura al binding del módulo exportador. Si el exportador cambia su variable, el importador ve el nuevo valor:

// contador.ts
export let cuenta = 0;
export function incrementar() { cuenta++; }

// main.ts
import { cuenta, incrementar } from "./contador.ts";
console.log(cuenta); // 0
incrementar();
console.log(cuenta); // 1  ← el binding se actualiza solo

El importador no puede reasignar cuenta: es un error temprano (SyntaxError) porque los bindings importados son inmutables desde fuera. Esta es la diferencia semántica más profunda frente a CommonJS, donde require te entrega el valor de module.exports en ese instante.

ℹ️
Namespace sellado

import * as mates produce un objeto exótico de namespace: sus propiedades son bindings vivos de solo lectura y no se le pueden añadir ni borrar claves. No es un objeto normal — es una vista fija de la superficie exportada del módulo.

Esta tabla resume la fractura semántica que define todo el nivel, y a la que volveremos en cada lección:

Aspecto ESM CommonJS
Resolución del grafo antes de evaluar en tiempo de ejecución
Lo que recibe el importador binding vivo valor en ese instante
Reasignar lo importado error temprano permitido
Análisis sin ejecutar garantizado heurístico
Tree shaking fiable conservador

Tree shaking: la recompensa del análisis estático

Como el conjunto de exportaciones e importaciones es conocido sin ejecutar, un bundler puede construir el grafo, marcar qué bindings se usan realmente y eliminar el código muerto (DCE, dead code elimination). Eso es el tree shaking, y solo es fiable sobre ESM:

{
  "name": "mi-lib",
  "type": "module",
  "sideEffects": false
}

sideEffects: false es una promesa al bundler: “importar un módulo de esta librería sin usar nada de él no cambia el mundo, puedes descartarlo entero”. Con ESM + esa pista, Rolldown y Rollup en 2026 podan agresivamente; con CommonJS, el análisis es conservador y casi siempre incluye de más.

El tree shaking no actúa solo: coopera con el minificador. El bundler marca las exportaciones no usadas y las descarta del grafo; el minificador (oxc-minify, terser) elimina después el código local que quedó sin referencias. La analizabilidad de ESM es la que arranca esa cadena — sin ella, ninguna herramienta se atrevería a borrar código por miedo a un efecto lateral oculto.

⚠️
El coste de los barrel files

Un index.ts que hace export * from de decenas de módulos (un barrel) es cómodo pero traicionero: si el bundler no puede probar que cada re-export carece de efectos, arrastra el barril entero aunque uses una sola función. Con sideEffects bien declarado el daño se mitiga, pero los barriles gigantes siguen siendo el enemigo número uno del tree shaking en 2026.

Las tres recompensas del modelo estático, que sostienen el resto del track:

🔒

Bindings vivos

El importador ve el estado actual del exportador, no una copia congelada en el momento del import.

🔁

Ciclos deterministas

Las dependencias circulares enlazan primero y evalúan después, sin objetos a medio poblar.

🌳

Tree shaking fiable

La forma conocida sin ejecutar permite podar exportaciones no usadas con seguridad.

La estática lo es todo

El error de novato es pensar que ESM es “CommonJS con otra sintaxis”. No lo es: es un cambio de modelo de evaluación. Al fijar la forma del grafo antes de ejecutar, ESM convierte el sistema de módulos en algo que las herramientas pueden razonar sin correr tu código. De esa única propiedad cuelga toda la cadena moderna: tree shaking, imports circulares que enlazan sin romper, análisis de tipos entre archivos, bundling determinista y el pre-bundle de Vite. Cuando entiendes que “import es un binding vivo resuelto en la fase de instanciación, no una llamada que devuelve un valor”, dejas de pelear con el tooling y empiezas a diseñar para él. ESM no describe cómo cargar código: describe un grafo que otros pueden optimizar.

⚔️ Comprueba la estática y el binding vivo
  1. Crea contador.ts con export let cuenta e incrementar(), e impórtalos en main.ts; verifica que cuenta cambia tras llamar a la función.
  2. Intenta reasignar cuenta en el importador y observa el error temprano.
  3. Añade una exportación que nadie usa, empaqueta con Vite en modo build y confirma que desaparece del bundle.
  4. Marca sideEffects: false en un package.json de prueba y razona qué gana el bundler.
  5. Provoca un ciclo entre dos módulos y observa cómo el orden de evaluación decide si un binding está listo o en su zona muerta.