Hoisting y node-linker: aislado vs plano
Cómo pnpm dibuja node_modules: el enlazado aislado por defecto frente al hoisting plano de npm, las dependencias fantasma que uno previene y el otro invita, y el ajuste node-linker.
Dos gestores pueden resolver el mismo grafo y aun así dibujar un node_modules radicalmente distinto en disco. Ese dibujo —qué paquete puede ver a qué otro— no es un detalle cosmético: determina si tu código puede importar por accidente algo que nunca declaraste. pnpm eligió por defecto un layout aislado que hace estructuralmente imposible ese accidente, en contraste con el layout plano que npm y yarn clásico heredaron. Entender la diferencia, y el ajuste node-linker que la controla, es entender de dónde salen las dependencias fantasma.
- Contrastar el
node_modulesplano (hoisted) con el aislado por enlaces simbólicos. - Entender qué es una dependencia fantasma y por qué el hoisting la provoca.
- Configurar el ajuste
node-linker:isolated,hoistedypnp. - Saber cuándo y cómo romper el aislamiento con
public-hoist-pattern.
node_modules: plano frente a aislado
El algoritmo clásico de npm y yarn aplana (hoisting): sube todas las dependencias, directas y transitivas, a un único node_modules en la raíz. Si tu app usa express, y express usa debug, ambos acaban lado a lado en el nivel superior. El motivo histórico fue evitar la duplicación y las rutas infinitas de las primeras versiones de npm.
pnpm hace algo distinto. Su node_modules es un árbol aislado por enlaces simbólicos: en la raíz solo aparecen tus dependencias directas, y cada una es un symlink a su versión real dentro de un .pnpm/ de contenido direccionable. Las dependencias de cada paquete cuelgan de él, no de la raíz.
# hoisted (npm): todo plano en la raiz
node_modules/
express/
debug/ <- transitiva, visible para tu codigo
ms/ <- transitiva de debug, tambien visible
# isolated (pnpm): solo lo directo en la raiz
node_modules/
express -> .pnpm/express@5.1.0/node_modules/express
.pnpm/
express@5.1.0/node_modules/ (aqui vive debug, no en la raiz)
debug@4.4.0/node_modules/ (aqui vive ms)
Ese .pnpm/ no duplica archivos: cada paquete es un enlace duro al store global de contenido direccionable de la máquina. Instalar la misma versión en veinte proyectos ocupa el espacio de una sola copia. El aislamiento, además de correcto, sale gratis en disco.
Resumiendo las consecuencias de cada geometría:
- Plano — todo visible para todos: cómodo para herramientas antiguas, pero invita fantasmas y colisiones.
- Aislado — cada paquete ve solo lo que declaró: estricto y correcto, con el
storeglobal deduplicando en disco.
flowchart TD subgraph Hoisted [Plano hoisted] R1[node_modules raiz] --> E1[express] R1 --> D1[debug transitiva visible] R1 --> M1[ms transitiva visible] end subgraph Isolated [Aislado por symlinks] R2[node_modules raiz] --> E2[express symlink] E2 --> D2[debug solo visible para express] D2 --> M2[ms solo visible para debug] end style D1 fill:#f38ba8,color:#11111b style M1 fill:#f38ba8,color:#11111b style D2 fill:#a6e3a1,color:#11111b
Dependencias fantasma: el bug que compila y luego revienta
Una dependencia fantasma es un paquete que tu código importa pero que no aparece en tu package.json. Ocurre así: con hoisting, debug está en la raíz porque express lo arrastró; tu editor lo autocompleta, tu import debug from 'debug' funciona, tus tests pasan. Nunca lo declaraste, pero está ahí gracias al aplanado.
El problema es que dependes de algo por accidente. El día que express deje de usar debug, o cambie a otra versión mayor, debug desaparece de la raíz o cambia bajo tus pies, y tu código —que jamás lo declaró— se rompe sin que tú hayas tocado nada. Es una dependencia real, con versión no controlada, invisible en tu manifiesto. En un monorepo el efecto se multiplica: un paquete puede usar fantasmas hoisteados por otro paquete del workspace, de modo que reordenar dependencias en un rincón rompe otro sin relación aparente.
El aislamiento de pnpm elimina la clase entera de bug: si no lo declaraste, no está en tu raíz, y el import falla de inmediato, en local, el primer día. Un fallo ruidoso y temprano en vez de una bomba silenciosa. La regla que impone es sana: solo puedes importar lo que declaras.
Si vienes de un proyecto hoisteado y temes tener fantasmas escondidos, migrar a isolated los saca a la luz sin esfuerzo: cada import que se rompa es un fantasma que usabas sin declarar. Herramientas como knip automatizan esa auditoría de imports frente a dependencias declaradas.
Cuidado con confundir dos patologías. Una dependencia fantasma es usar algo no declarado. Un doppelganger es tener dos copias de la misma versión de un paquete en sitios distintos del árbol porque sus contextos de peers difieren —dos instancias que deberían ser una—. El aislamiento previene los fantasmas por construcción; los doppelgangers son un problema aparte, de deduplicación, que pnpm minimiza pero no siempre puede eliminar cuando los peers obligan a instancias separadas.
node-linker: las tres estrategias
El ajuste node-linker (en .npmrc o pnpm-workspace.yaml) controla cómo se materializa el árbol en disco:
isolated (por defecto)
El árbol enlazado por symlinks. Previene fantasmas, comparte el store global (ahorro de disco), y es el modo recomendado salvo incompatibilidad concreta.
hoisted
Un node_modules plano como el de npm. Se usa para herramientas que asumen un layout plano o no siguen symlinks. Reintroduce el riesgo de fantasmas.
pnp
Plug’n’Play: sin node_modules, la resolución la sirve un runtime. Máxima velocidad y estrictez, pero requiere soporte de tu toolchain.
# el valor por defecto; explicito para que el equipo lo vea
node-linker=isolated
En pnpm 10 esta configuración también puede vivir en pnpm-workspace.yaml, que centraliza los ajustes del monorepo en un único archivo junto a la lista de paquetes:
packages:
- packages/*
nodeLinker: isolated
Cuándo y cómo romper el aislamiento
A veces una herramienta espera encontrar un paquete en la raíz —clásicamente algún plugin de ESLint, o utilidades que hacen resolución poco ortodoxa—. Antes de cambiar a hoisted (que renuncia a toda la protección), pnpm ofrece bisturíes finos:
# sube a la raiz solo lo que casa con el patron, nada mas
public-hoist-pattern[]=*eslint-plugin*
public-hoist-pattern[]=@types/*
# el martillo: aplana todo (equivale casi a npm). Ultimo recurso.
# shamefully-hoist=true
Fíjate en el nombre: shamefully-hoist (“aplanar vergonzosamente”). El equipo de pnpm lo bautizó así a propósito, para que cada vez que lo escribas recuerdes que estás renunciando a una garantía. La jerarquía correcta de decisiones es escalonada:
- Primero, declara la dependencia que te falta: casi siempre el fantasma debería estar en tu
package.json, y añadirlo es la solución real. - Si de verdad es una herramienta que necesita el layout plano, usa un
public-hoist-patternacotado al patrón mínimo. - Solo como último recurso, y documentándolo en un comentario, recurre a
node-linker=hoistedoshamefully-hoist.
Existe también hoist-pattern, que sube paquetes dentro del .pnpm/ virtual (no a la raíz pública): útil para casos internos sin exponerlos a tu código. La diferencia entre hoist-pattern y public-hoist-pattern es precisamente quién acaba viendo el paquete, y elegir el más restrictivo que funcione es la disciplina correcta.
La lección profunda no va de symlinks: va de cómo la forma de un sistema codifica sus reglas. El node_modules plano no “permite” las dependencias fantasma por descuido; su geometría las hace inevitables, porque poner todo al alcance de todos convierte cualquier transitiva en importable. pnpm invirtió la política diseñando una estructura donde lo indebido es, sencillamente, inalcanzable: no hace falta un linter que te regañe por importar algo no declarado, porque el símbolo no existe en tu ámbito. Esto es diseño por construcción frente a diseño por convención, y es un patrón que reconocerás por todo el software serio —tipos que hacen irrepresentables los estados inválidos, capabilities que sustituyen a los permisos globales, aislamiento de procesos frente a disciplina compartida—. Cuando en 2026 elijas isolated por defecto no estás optimizando disco (aunque el store direccionable también lo hace): estás eligiendo que la corrección sea estructural en vez de opcional. Y cuando te veas tentado de escribir shamefully-hoist, el propio nombre te está pidiendo que primero declares lo que en realidad usas.
- Inspecciona tu
node_modules: bajoisolatedverás symlinks en la raíz y el.pnpm/real detrás; compáralo con el layout plano de un proyecto npm. - Importa a propósito un paquete transitivo que no esté en tu
package.jsony observa que pnpm hace fallar elimport. - Declara ese paquete con
pnpm add, confirma que ahora elimportfunciona, y razona por qué eso es lo correcto. - Añade un
public-hoist-patternacotado en.npmrcpara un patrón concreto y verifica ennode_modulesque solo eso sube a la raíz. - Cambia temporalmente a
node-linker=hoisted, reinstala y observa cómo reaparecen las transitivas en la raíz; luego vuelve aisolated.