wandres.dev
LOCKFILES · reproducibilidad

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.

⏱ 15 min

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.

🎯 Al terminar esta lección sabrás
  • Contrastar el node_modules plano (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, hoisted y pnp.
  • 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 store global 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.

📝
Fantasma no es lo mismo que doppelganger

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-pattern acotado al patrón mínimo.
  • Solo como último recurso, y documentándolo en un comentario, recurre a node-linker=hoisted o shamefully-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 estructura es la política

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.

⚔️ Caza un fantasma
  1. Inspecciona tu node_modules: bajo isolated verás symlinks en la raíz y el .pnpm/ real detrás; compáralo con el layout plano de un proyecto npm.
  2. Importa a propósito un paquete transitivo que no esté en tu package.json y observa que pnpm hace fallar el import.
  3. Declara ese paquete con pnpm add, confirma que ahora el import funciona, y razona por qué eso es lo correcto.
  4. Añade un public-hoist-pattern acotado en .npmrc para un patrón concreto y verifica en node_modules que solo eso sube a la raíz.
  5. Cambia temporalmente a node-linker=hoisted, reinstala y observa cómo reaparecen las transitivas en la raíz; luego vuelve a isolated.