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.
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.
- 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:
importreferencia, 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.
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.
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.
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.
- Crea
contador.tsconexport let cuentaeincrementar(), e impórtalos enmain.ts; verifica quecuentacambia tras llamar a la función. - Intenta reasignar
cuentaen el importador y observa el error temprano. - Añade una exportación que nadie usa, empaqueta con Vite en modo build y confirma que desaparece del bundle.
- Marca
sideEffects: falseen unpackage.jsonde prueba y razona qué gana el bundler. - 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.