ESM en Node.js: import/export nativo, interop con CommonJS y configuración package.json

Node.js soporta el sistema de módulos ECMAScript (ESM) nativo desde la versión 12 y de forma estable desde la 14. Usar ESM nativo en lugar de CommonJS o de transpiladores como Babel tiene ventajas reales: tree-shaking más eficiente, análisis estático de importaciones, top-level await y mejor interoperabilidad con el ecosistema de paquetes modernos.

Activar ESM: type:module en package.json

La forma más sencilla de activar ESM en un proyecto Node.js es añadir "type": "module" al package.json. Todos los archivos .js del proyecto pasan a tratarse como módulos ESM:

// package.json
{
  "name": "mi-proyecto",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js"
}

// index.js — ya es ESM sin extensión .mjs
import { readFile } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = join(__filename, '..');

const contenido = await readFile(join(__dirname, 'config.json'), 'utf-8');

Extensiones .mjs y .cjs

Si no quieres cambiar "type" en el package.json (porque tienes código CJS y ESM mezclados), puedes usar extensiones explícitas:

// .mjs — siempre ESM, sin importar el "type" del package.json
// modulo.mjs
export function saludar(nombre) {
  return `Hola, ${nombre}`;
}

// .cjs — siempre CommonJS
// helpers.cjs
const { readFileSync } = require('fs');
module.exports = { readFileSync };

// En un archivo .mjs puedes importar .cjs
import helpers from './helpers.cjs'; // Interop automático

// Pero NO al revés: no puedes require() un .mjs
// require('./modulo.mjs'); // Error — ESM no es sincrónico

Las extensiones son obligatorias en ESM

En CommonJS se podía omitir la extensión en los require. En ESM nativo de Node, las extensiones son obligatorias en los imports locales (no en paquetes npm):

// CJS — extensión opcional
const utils = require('./utils'); // Funciona

// ESM — extensión OBLIGATORIA
import utils from './utils';     // Error: Cannot find module
import utils from './utils.js';  // Correcto

// Los paquetes npm no necesitan extensión (Node los resuelve por package.json)
import express from 'express';   // Correcto
import { serve } from 'hono';    // Correcto

import.meta: el reemplazo de __dirname y __filename

En ESM, las variables __dirname y __filename de CommonJS no existen. Se reemplazan con import.meta.url:

import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// Equivalente a __filename
const __filename = fileURLToPath(import.meta.url);

// Equivalente a __dirname
const __dirname = dirname(__filename);

// Leer un archivo relativo al módulo actual
import { readFile } from 'fs/promises';
const config = JSON.parse(
  await readFile(join(__dirname, 'config.json'), 'utf-8')
);

// import.meta.url también es útil para comprobar si es el módulo principal
if (import.meta.url === `file://${process.argv[1]}`) {
  // Ejecutado directamente, no importado
  main();
}

Top-level await

Los módulos ESM permiten usar await en el nivel superior del módulo, fuera de cualquier función async. Esto simplifica la inicialización asíncrona:

// config.js (ESM)
const { DB_URL } = process.env;

// Top-level await — esto espera antes de exportar
const conexion = await conectarDB(DB_URL);
const configRemota = await fetch('/api/config').then(r => r.json());

export { conexion, configRemota };

// index.js — la importación de config.js espera automáticamente a que se resuelva
import { conexion, configRemota } from './config.js';
// Aquí conexion ya está inicializada
console.log('Conectado a:', conexion.host);

Importar paquetes CommonJS desde ESM

La mayoría de paquetes npm más antiguos siguen siendo CommonJS. Node.js permite importarlos desde ESM con algunas limitaciones:

// Importar un paquete CJS desde ESM
import lodash from 'lodash'; // Solo default export disponible
const { cloneDeep } = lodash;

// O desestructurar directamente (funciona pero solo con default)
import { cloneDeep } from 'lodash'; // Puede funcionar según la versión de Node

// Si el paquete exporta un objeto: solo se puede importar como default
import express from 'express'; // Correcto
const app = express();

// Los paquetes CJS NO pueden importarse con named exports directamente
// (a menos que Node detecte las claves estáticamente, lo cual no siempre ocurre)

import() dinámico: lazy loading

// Importación dinámica — devuelve una Promise con el módulo
async function cargarFeature(tipo) {
  const modulo = await import(`./features/${tipo}.js`);
  return modulo.default;
}

// Carga condicional según el entorno
const logger = await import(
  process.env.NODE_ENV === 'production'
    ? './logger-produccion.js'
    : './logger-desarrollo.js'
);

// Útil también en módulos ESM para importar código CJS que usa require()
const { createRequire } = await import('module');
const require = createRequire(import.meta.url);
const paqueteViejo = require('./paquete-cjs-antiguo');

Exports condicionales en package.json

El campo exports en package.json permite definir exports distintos según si el importador usa ESM o CJS, y también restringir qué partes del paquete son importables:

// package.json de una librería que soporta ambos sistemas
{
  "name": "mi-libreria",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  }
}

// Esto evita que se hagan imports de rutas internas no públicas:
// import algo from 'mi-libreria/src/interno'; // Error — no está en exports

La transición a ESM nativo en Node.js está bien asentada en 2024. La mayoría de los paquetes populares ya publican versiones ESM, y herramientas como Vitest, Next.js 14 y Vite funcionan exclusivamente o principalmente con ESM. Adoptar ESM nativo desde el principio en proyectos nuevos es la decisión más alineada con la dirección del ecosistema.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP