wandres.dev
EMPAQUETAR UNA LIBRERÍA · ESM, CJS, tipos

Externals: no empaquetes lo que el consumidor ya tiene

Una app incrusta sus dependencias porque es el artefacto final; una librería hace justo lo contrario: deja fuera del bundle todo lo que el consumidor puede resolver por su cuenta. Empaquetar una dependencia dentro de tu librería duplica código, rompe el estado compartido de singletons como React y sabotea el tree shaking del consumidor. Marcar las dependencias como external, entender por qué peerDependencies existe precisamente para no empaquetar, y dominar la regla de que tu dist debe contener tu código y solo tu código: las aristas del grafo que salen de tu paquete las cierra quien te instala, no tú.

⏱ 16 min

Una app incrusta sus dependencias dentro del bundle porque es el último eslabón: nadie más va a resolverlas, así que las mete todas y cierra el paquete. Una librería debe hacer exactamente lo contrario. Cada dependencia que empaquetas dentro de tu dist es código que viajará duplicado en el árbol del consumidor, que romperá el estado compartido de las librerías que exigen una sola copia, y que le negará a quien te instala la posibilidad de podar y versionar lo que ya tenía. La regla que gobierna esta lección es tajante: tu paquete publicado contiene tu código y solo tu código. Todo lo demás son aristas que salen de tu grafo y que cierra el consumidor, no tú.

🎯 Al terminar esta lección sabrás
  • Entender por qué empaquetar una dependencia en una librería es casi siempre un error.
  • Marcar dependencias como external para que salgan del bundle y las resuelva el consumidor.
  • Distinguir dependencies de peerDependencies y saber qué promete cada una.
  • Reconocer los tres daños de incrustar: duplicación, singletons rotos y tree shaking saboteado.

Externalizar: dejar las aristas abiertas

Cuando el bundler recorre tu grafo y encuentra un import a react, tiene dos opciones: seguir esa arista, entrar en el código de React y copiarlo dentro de tu salida, o detenerse en el borde y dejar el import tal cual, apuntando a un react que alguien resolverá después. Marcar una dependencia como external es elegir la segunda: le dices al bundler que no cruce esa arista, que la deje abierta, que confíe en que el consumidor tiene su propia copia de react y sabrá conectarla.

// rollup.config.js — externalizar es cerrar el grafo en el borde
export default {
  input: "src/index.ts",
  external: ["react", "react-dom", "lodash-es"],
  output: { file: "dist/index.js", format: "es" },
};

El resultado es un dist que preserva sus import a las dependencias en vez de incrustarlas. Tu código sale intacto; sus referencias a lo ajeno quedan como especificadores desnudos que el bundler del consumidor resolverá contra su node_modules. Esa es la forma correcta de un paquete de librería: un artefacto que contiene tu lógica y una lista de puentes hacia lo que espera encontrar montado del otro lado.

En la práctica no enumeras cada paquete a mano. El convenio es externalizar todo lo que figure en dependencies y peerDependencies del package.json, porque justamente eso es lo que el consumidor va a instalar. Las herramientas modernas de empaquetado de librerías lo hacen por defecto: leen tu manifiesto y tratan como external cuanto declaras depender, de modo que solo acaba en el bundle tu propio código y, como mucho, lo que tengas en devDependencies y uses de verdad en runtime.

dependencies frente a peerDependencies

No todas las dependencias que dejas fuera son iguales, y la diferencia entre dependencies y peerDependencies es una de las más malentendidas del empaquetado. Una dependency normal es tuya: tu librería la necesita, tú eliges su versión, y el gestor de paquetes instalará la copia que a ti te va bien, aunque el consumidor no sepa que existe. Es la relación por defecto para utilidades internas que no tienen que ser compartidas con nadie.

Una peerDependency es una declaración distinta: dice que tu librería necesita un paquete que el consumidor ya tiene y debe compartir contigo. El caso canónico es un plugin de React: tu librería usa React, pero no quiere su propia copia —quiere exactamente la misma instancia que usa la app, porque React guarda estado interno (los hooks, el contexto) que se rompe si conviven dos copias. Con peerDependencies le dices al consumidor: “no te instalo React, te exijo que tú tengas uno compatible y lo compartas conmigo”.

{
  "name": "mi-plugin-react",
  "peerDependencies": {
    "react": ">=18"
  },
  "devDependencies": {
    "react": "^19"
  }
}

Fíjate en el doble juego: react aparece en peerDependencies —lo exiges al consumidor con un rango amplio— y en devDependencies —lo instalas solo para compilar y testear en local—. Lo que nunca aparece es en dependencies, porque eso forzaría una segunda copia. La regla mental es limpia: dependencies para lo que es tuyo y puede duplicarse sin daño; peerDependencies para lo que debe ser único y compartido con quien te consume.

ℹ️
El rango de una peerDependency es una promesa de compatibilidad

Cuando escribes react: >=18 en peerDependencies estás firmando qué versiones de React soporta tu librería. Un rango demasiado estrecho —fijar una versión exacta— hace tu paquete imposible de instalar junto a otros; uno demasiado ancho promete una compatibilidad que quizá no cumples. Este rango es parte del contrato semántico del que hablará el resto del nivel: ampliarlo o estrecharlo es un cambio que tus consumidores notan, y por eso pertenece al mismo registro de decisiones que los formatos que ofreces o los tipos que publicas.

Los tres daños de incrustar

Conviene ver con nitidez qué rompes exactamente cuando empaquetas una dependencia que deberías haber externalizado, porque los tres daños son distintos y los tres son reales. El primero es la duplicación: si tu librería incrusta lodash y la app del consumidor también lo usa, el bundle final carga dos copias del mismo código. Multiplícalo por varias librerías que hacen lo mismo y el peso se dispara sin que nadie lo note hasta medir.

El segundo es el más insidioso: los singletons rotos. Ciertas librerías —React, Vue, el cliente de un store, un contexto de i18n— dependen de que exista una sola instancia en todo el árbol. Si tu librería trae su propia copia de React incrustada, tus componentes usan un React distinto del de la app, y cosas como los hooks o el Context fallan con errores desconcertantes que no apuntan a la causa. Es el bug clásico de “invalid hook call”: dos Reacts donde debía haber uno.

El tercero es que saboteas el tree shaking del consumidor. Si dejas lodash-es como external, quien te instala importa solo las funciones que tu código realmente usa y su bundler poda el resto. Si lo incrustas ya resuelto en tu dist, congelas esa decisión: el consumidor se traga lo que tú metiste, sin poder sacudir nada ni deduplicarlo contra su propia copia. Externalizar no es solo higiene de peso; es devolverle al consumidor el control sobre las dependencias que son, al fin y al cabo, suyas.

flowchart TD
A[tu libreria importa react] --> B{lo marcas external}
B -->|si| C[el import sale intacto]
C --> D[el consumidor conecta SU react]
D --> E[una sola instancia, hooks ok]
B -->|no, lo incrustas| F[tu dist trae su propio react]
F --> G[dos copias en el arbol]
G --> H[singleton roto: invalid hook call]
style E fill:#a6e3a1,color:#11111b
style H fill:#f38ba8,color:#11111b
⚠️
La excepción: cuando sí quieres empaquetar algo

La regla de externalizar tiene un matiz. Una utilidad pequeña, sin estado compartido, que solo tú usas y que no aporta valor como dependencia visible, a veces se incrusta a propósito para reducir la superficie de instalación del consumidor —es lo que a veces se llama bundling de dependencias privadas. La prueba es doble: debe ser un paquete que el consumidor jamás compartiría contigo (no un singleton) y cuyo peso incrustado sea despreciable. Ante la duda, externaliza: incrustar es la optimización arriesgada, externalizar es el defecto seguro.

Peers opcionales y la externalización automática

No toda peer dependency es obligatoria. A veces tu librería puede integrarse con otro paquete si está presente, pero funciona igual sin él —un adaptador que aprovecha un framework opcional, un plugin que mejora si detecta cierta librería—. Para ese caso, peerDependenciesMeta marca un peer como opcional, de modo que el gestor no avise si el consumidor no lo tiene instalado.

{
  "peerDependencies": {
    "react": ">=18",
    "redux": ">=5"
  },
  "peerDependenciesMeta": {
    "redux": { "optional": true }
  }
}

Aquí react es un peer obligatorio y redux uno opcional: tu librería lo usa si está, pero no rompe la instalación si falta. La contrapartida es que tu código debe comprobar en runtime si el peer opcional existe antes de tocarlo, porque su presencia dejó de estar garantizada. Es un contrato más flexible, pero también uno que traslada al autor la responsabilidad de degradar con elegancia cuando el peer no aparece.

En la práctica no mantienes la lista de externals a mano. Las herramientas de empaquetado de librerías —tsdown, unbuild— externalizan por defecto todo lo que figure en dependencies y peerDependencies, leyéndolo del propio manifiesto. Así, declarar bien tus dependencias no solo documenta el contrato: configura el build, porque la misma lista que el consumidor instalará es la que tu bundler deja fuera. Un manifiesto honesto produce, casi gratis, un dist correctamente externalizado.

📝
El subpath de una dependencia externa también hay que externalizarlo

Externalizar react no basta si tu código importa react/jsx-runtime o react-dom/client: esos subpaths son especificadores distintos y el bundler los seguiría a menos que también los trates como externos. Las herramientas modernas resuelven esto externalizando por prefijo —cualquier import que empiece por un paquete declarado— pero si configuras el build a mano, recuerda que cada subpath de una dependencia externa es otra arista que debes dejar abierta, no solo la raíz del paquete.

🔌

external

Le dices al bundler que no cruce la arista: el import sale intacto y lo resuelve el consumidor contra su propio árbol.

📥

dependencies

Lo que es tuyo y puede duplicarse sin daño. El gestor instala la copia que tú eliges, transparente al consumidor.

🤝

peerDependencies

Lo que debe ser único y compartido —React, Vue— con un rango que promete qué versiones soportas.

💥

El singleton roto

Incrustar un React duplicado rompe hooks y contextos: el bug que no apunta a su causa. Externalizar lo evita.

El reverso de externalizar es que tu librería deja de garantizar por sí sola que la versión correcta esté presente: esa garantía se traslada al gestor de paquetes del consumidor, que resuelve tus peers contra el árbol completo y avisa si falta uno o si el rango no se satisface. Publicar con externals bien declarados es, en el fondo, delegar la resolución en quien tiene la vista completa del árbol —el único que puede deduplicar tu React con el de las otras diez librerías que también lo piden— en lugar de imponer tu copia a ciegas.

ℹ️
Un peer no satisfecho avisa temprano, no revienta tarde

Cuando el consumidor instala tu librería y no cumple un peer obligatorio, el gestor lo señala en la instalación con un aviso de dependencia peer no resuelta, no con un crash misterioso en producción. Esa es justo la virtud de declararlo como peer en vez de incrustarlo: el problema aflora pronto, en el terreno del consumidor, con un mensaje que nombra el paquete y el rango que falta. Incrustar habría escondido el conflicto hasta que dos copias se encontraran en runtime, donde el síntoma no apunta jamás a su causa.

Tu paquete contiene tu código; el resto son aristas que cierra el consumidor

El principio que ordena esta lección es geométrico antes que técnico: tu librería es un subgrafo, no un grafo cerrado. Tiene nodos —tus módulos— y aristas que salen hacia el exterior —tus import a terceros—, y la decisión fundamental al empaquetar es qué haces con esas aristas que apuntan afuera. Una app las sigue todas y las absorbe, porque es el grafo final y nadie va a resolver nada por ella. Una librería hace lo contrario: las deja abiertas, como cabos que el consumidor anudará a su copia de cada dependencia. Externalizar es, literalmente, respetar que tu grafo no es el grafo completo, sino una pieza que se ensambla dentro de otro mayor que no controlas. Y de ese respeto nacen los tres beneficios simétricos a los tres daños de incrustar: no duplicas código porque no traes copias de lo que ya vive en el árbol del consumidor; no rompes singletons porque dejas que React, Vue o el store existan una sola vez, resueltos por quien monta el árbol entero; y no saboteas el tree shaking porque devuelves a tu consumidor el poder de podar y deduplicar unas dependencias que, al fin y al cabo, son suyas y no tuyas. La distinción entre dependencies y peerDependencies es la gramática fina de esta misma idea: unas son aristas hacia cosas que puedes tener en privado y duplicar sin daño, otras son aristas hacia cosas que exiges compartir porque duplicarlas rompe el mundo. Quien empaqueta una librería como si fuera una app —cerrando todas las aristas, incrustándolo todo para que “no falte nada”— produce paquetes obesos que rompen los singletons de quien los instala y le niegan cualquier control. Quien entiende que su paquete es un subgrafo abierto publica una pieza limpia que encaja sin fricción en árboles que jamás verá. La materia de tu dist es tu código, exactamente tu código y nada más que tu código; todo lo demás son promesas de conexión que el consumidor cumple en su terreno, y tu única obligación es declararlas con honestidad.

⚔️ Cierra el grafo por el borde correcto
  1. Empaqueta una librería que importe lodash-es sin externalizarlo, mide el dist, y compáralo con la versión que lo marca como external.
  2. Publica un plugin de React con react en peerDependencies y devDependencies, y confirma que tu dist no contiene una copia de React.
  3. Provoca el bug del singleton roto incrustando React a propósito y observa el “invalid hook call” cuando conviven dos copias.
  4. Configura tu build para externalizar automáticamente todo lo que figure en dependencies y peerDependencies, sin listarlos a mano.
  5. Elige una dependencia dudosa —pequeña, sin estado— y argumenta con la prueba doble si conviene incrustarla o dejarla external.