El algoritmo de resolución de módulos de Node
Cómo Node convierte un especificador en un archivo real: el recorrido ascendente de node_modules, la prueba de extensiones, los index implícitos y la ruptura estricta que introduce ESM.
Entre que escribes import { z } from "zod" y que el motor ejecuta ese archivo ocurre un algoritmo invisible y sorprendentemente intrincado: la resolución de módulos. Node toma un texto —el especificador— y lo convierte en una ruta concreta del disco. Entender ese recorrido paso a paso es lo que explica los Cannot find module, las dependencias fantasma y por qué ESM y CommonJS no resuelven igual el mismo import.
- Distinguir especificadores relativos, absolutos y desnudos (bare).
- Recorrer el algoritmo de CommonJS: archivo, extensiones, index y el ascenso por node_modules.
- Contrastar ESM: extensiones obligatorias, sin index implícito y con
exportscomo puerta. - Depurar una resolución fallida con las herramientas del propio runtime.
Especificadores: tres familias
Un especificador es el texto que sigue a from. Antes de resolver nada, Node lo clasifica en una de tres familias, y esa clase decide todo el algoritmo posterior. Confundir las tres es la raíz de la mayoría de los errores de resolución.
Relativo
Empieza con ./ o ../. Se resuelve contra la URL del módulo que lo importa. Es determinista: siempre apunta al mismo archivo desde el mismo origen.
Absoluto
Una ruta completa o una URL file://. Poco frecuente en código de aplicación; lo genera sobre todo el tooling.
Desnudo (bare)
react, zod, lodash/merge. No empieza por punto ni barra: dispara la búsqueda ascendente en node_modules. Es el caso interesante.
En código, las familias conviven y se distinguen a simple vista por su primer carácter:
import a from "./local.js"; // relativo, contra la URL actual
import b from "/abs/mod.js"; // absoluto
import c from "zod"; // desnudo: el paquete zod
import d from "zod/mini"; // desnudo: un subpath de zod
import e from "node:fs"; // modulo interno del runtime
En un especificador desnudo, lo que va antes de la primera barra es el nombre del paquete y lo que va después es un subpath dentro de él. lodash/merge significa “el subpath merge del paquete lodash”, y ese subpath lo arbitra el campo exports (lección 2).
Hay además una categoría que corta el algoritmo antes de empezar: los módulos internos del runtime. node:fs, node:path o el histórico fs a secas se devuelven directamente sin tocar el disco. El prefijo node: es la forma explícita y recomendada en 2026: elimina toda ambigüedad con un hipotético paquete de npm del mismo nombre y deja claro a la lectura que es una API del propio Node, no una dependencia.
El algoritmo clásico de CommonJS
Cuando un módulo M hace require(X), Node aplica un procedimiento que la especificación describe casi literalmente así:
require(X) desde el modulo M:
1. si X es un modulo interno (node:fs, path) -> devolverlo
2. si X empieza con "/", "./" o "../":
a. LOAD_AS_FILE probar X, luego X.js, X.json, X.node
b. LOAD_AS_DIRECTORY leer el package.json "main", o caer a index.js
3. si X es desnudo (LOAD_NODE_MODULES):
por cada carpeta desde M hacia la raiz del disco,
probar CARPETA/node_modules/X como archivo o como directorio
4. si nada coincide -> lanzar "Cannot find module"
Aquí viven dos comportamientos que definen la ergonomía —y las trampas— de CommonJS. El primero es la prueba de extensiones: si escribes require("./util") sin extensión, Node intenta util, util.js, util.json y util.node en ese orden. Cómodo, pero cada intento fallido es un acceso a disco. El segundo es el index implícito: importar una carpeta carga su index.js si no hay main.
Lo verdaderamente estructural es el paso 3: el ascenso. Desde /app/src/a.js, Node prueba /app/src/node_modules, luego /app/node_modules, luego /node_modules, subiendo hasta la raíz. Este recorrido explica el hoisting de los package managers y las temidas dependencias fantasma: un módulo instalado arriba resulta accesible desde abajo aunque nadie lo declarara.
Una vez resuelto, el resultado importa por partida doble: la ruta absoluta final es la clave de la caché de módulos. Node ejecuta cada archivo una sola vez y guarda su module.exports; los siguientes require del mismo archivo devuelven la instancia cacheada. Por eso dos especificadores que resuelven al mismo archivo comparten estado, y dos que resuelven a archivos distintos —aunque su contenido sea idéntico— no lo comparten. La identidad de un módulo es su ruta resuelta, no su nombre: esa igualdad es el origen de casi todo, desde los singletons que funcionan hasta los que se duplican.
Un vestigio histórico conviene conocerlo para descartarlo: la variable NODE_PATH añadía carpetas globales al ascenso. Hoy se considera mala práctica, porque hace que la resolución dependa del entorno de la máquina y no solo del proyecto, rompiendo la reproducibilidad. Que la resolución dependa exclusivamente de archivos versionados —tu package.json, tu lockfile, tu árbol de node_modules— y no de variables sueltas del sistema, es una de las razones por las que los builds de 2026 son deterministas.
flowchart TD A[require de un nombre desnudo] --> B[buscar en node_modules local] B -->|encontrado| OK[cargar el modulo] B -->|no esta| C[subir a la carpeta padre] C --> D[buscar en su node_modules] D -->|encontrado| OK D -->|no esta| E[repetir hasta la raiz del disco] E -->|nunca aparece| ERR[error Cannot find module]
Que la búsqueda suba hasta la raíz tiene un efecto secundario venenoso. Un paquete que ni siquiera declaraste en tu package.json puede resolverse igual, porque otra dependencia lo dejó aplanado en un node_modules superior. Tu código funciona hoy y estalla mañana, cuando esa dependencia transitiva cambie de versión o desaparezca. Es la dependencia fantasma, y es un fallo de arquitectura, no de código. El store de enlaces simbólicos de pnpm existe justamente para matarla: solo expone lo que declaraste, de modo que un import no declarado falla al instante en tu máquina, en vez de en producción seis meses después.
La ruptura de ESM
El resolutor de ESM (la especificación lo llama ESM_RESOLVE) no es azúcar sobre el de CommonJS: es un algoritmo distinto y deliberadamente más estricto. Las diferencias no son cosméticas, cambian qué código compila.
- Extensiones obligatorias. Debes escribir
./util.js, no./util. No hay prueba de extensiones; lo que escribes es lo que se carga. - Sin index implícito. Importar una carpeta falla salvo que su
package.jsonla mapee conexports. exportsmanda. En paquetes que lo definen, ese campo decide qué es accesible y bloquea todo lo demás.- Basado en URL. Los especificadores son URLs:
file://, query strings y fragmentos cuentan y pueden afectar la caché del módulo.
Antes incluso de resolver, Node decide en qué formato interpretar cada archivo. La extensión manda: .mjs es siempre ESM y .cjs siempre CommonJS. Para .js, decide el campo type del package.json más cercano hacia arriba: con type: module el archivo es ESM, sin él queda como CommonJS. Cuando ni la extensión ni el type bastan, la detección de sintaxis inspecciona el archivo en busca de import o export para deducir el formato, un mecanismo cada vez más activado por defecto en las versiones recientes.
| Aspecto | CommonJS | ESM |
|---|---|---|
| Extensiones | se prueban (.js, .json, .node) |
obligatorias en el import |
| Carpeta | index.js implícito |
falla salvo que haya exports |
| Carga del grafo | síncrona, dentro de require |
asíncrona, resuelta antes de ejecutar |
| Especificador | ruta de disco | URL |
Esta rigidez tiene recompensa. Como ESM resuelve y carga todo el grafo de forma asíncrona antes de ejecutar una sola línea, habilita capacidades que CommonJS no puede ofrecer: el top-level await, una carga diferida limpia con import() y la posibilidad de analizar el grafo completo sin ejecutarlo. Resolver, en ESM, no es solo encontrar el archivo; es lo que define el modelo de ejecución del módulo.
Un ejemplo que lo deja claro: en ESM moderno, importar un JSON exige un import attribute, import data from "./x.json" with { type: "json" }. La resolución encuentra el archivo igual que cualquier otro; el atributo solo le dice al runtime cómo interpretar el recurso ya resuelto. Separar mentalmente las dos fases —encontrar y luego cargar— evita confundir un error de resolución con uno de formato.
El error de intuición más común es tratar la resolución como “Node ya se apañará para encontrarlo”. No: tanto CJS require como ESM_RESOLVE están escritos como procedimientos deterministas en el estándar, paso por paso, sin ambigüedad. Cuando interiorizas eso, los misterios se disuelven. El ascenso por node_modules deja de ser magia y se vuelve la causa exacta de las dependencias fantasma —y de por qué el store de enlaces simbólicos de pnpm, al no aplanar, las hace desaparecer y te obliga a declarar lo que usas. La rigidez de ESM deja de ser una molestia y se revela como una corrección deliberada: sin prueba de extensiones ni index adivinado, la resolución es predecible, cacheable y paralelizable, justo lo que un grafo de módulos a escala necesita. En 2026, con Node 24 LTS y require(ESM) ya estable, los dos algoritmos interoperan más que nunca, pero nunca se fusionan: siguen siendo dos contratos distintos que conviene conocer por separado.
Depurar la resolución
Cuando una resolución falla, no adivines: instrumenta. El runtime expone sondas precisas para ver cada intento y la ruta final sin llegar a ejecutar el módulo.
NODE_DEBUG=module node app.js # traza cada intento de resolucion
node -e "console.log(require.resolve('zod'))" # ruta final en CommonJS
node -e "console.log(require.resolve.paths('zod'))" # las carpetas candidatas del ascenso
node --input-type=module -e "console.log(import.meta.resolve('zod'))" # URL final en ESM
import.meta.resolve (estable desde Node 20) es la mejor sonda para ESM: devuelve la URL a la que resolvería un especificador sin importarlo, ideal para verificar subpaths y conditions. En CommonJS, require.resolve.paths te muestra literalmente la lista de carpetas del ascenso que Node recorrería.
Node permite además interceptar la resolución con los module customization hooks, registrando un hook resolve propio con register. Es la puerta oficial para personalizar el algoritmo desde fuera, y la base de cómo runners como tsx resuelven y ejecutan .ts en tiempo de ejecución sin un paso de compilación previo. Cuando una herramienta parece resolver por arte de magia, casi siempre hay un hook de estos detrás.
Recuerda que estas sondas usan el resolutor de Node. Un bundler resuelve con reglas ampliadas —alias, extensiones extra, otras conditions activas (lección 5)— así que un especificador puede resolver distinto en node que en vite build. Cuando algo funciona en un sitio y falla en el otro, la causa casi siempre es esa: dos resolutores con configuraciones distintas mirando el mismo import. Verifica en el entorno donde falla, no en el que funciona.
- Crea
a/b/c.jsque hagarequire("izq")conizqinstalado solo en la raíz; ejecuta conNODE_DEBUG=moduley observa el ascenso intento a intento. - Renombra un import ESM de
./utila./util.jsy comprueba cuál de los dos compila y cuál lanza error. - Usa
import.meta.resolvepara resolver un subpath de un paquete y compáralo con lo que darequire.resolve. - Explica con tus palabras por qué una dependencia fantasma funciona con npm aplanado y falla con el store de pnpm.