Los límites del tree shaking: efectos ocultos y @__PURE__
El tree shaking es un análisis conservador, y sus fronteras las dibujan los efectos secundarios que no puede descartar: una llamada de nivel superior que quizá muta un global, un import puro de efectos que jamás se poda, un enum de TypeScript que compila a algo opaco. Cuando el bundler no puede probar que una expresión es pura, la conserva. Las anotaciones /* @__PURE__ */ y /* @__NO_SIDE_EFFECTS__ */ son la vía para que el autor prometa esa pureza a mano y devuelva la poda que el análisis, por prudencia, había bloqueado.
El tree shaking no es un oráculo: es un análisis conservador que, ante la duda, conserva. Sus límites no son fallos, sino la sombra de una regla sensata —si no puedo probar que eliminar esto es seguro, lo dejo—. Esa regla tropieza con toda una fauna de efectos que el bundler no sabe descartar: una llamada de nivel superior que quizá muta un global, un import que existe solo por su efecto, un enum de TypeScript que compila a un artefacto opaco, un decorador que registra por lo bajo. En todos ellos, el análisis se planta y guarda el código por si acaso. Las anotaciones /* @__PURE__ */ y /* @__NO_SIDE_EFFECTS__ */ son la puerta por la que el autor, que sí sabe la verdad, le devuelve al bundler la certeza que le faltaba.
- Reconocer los efectos secundarios ocultos que obligan al bundler a conservar código.
- Distinguir el
importpuro de efectos, que nunca se poda, del import de bindings usados. - Entender por qué ciertos patrones —enums, decoradores, IIFE— rompen el tree shaking.
- Usar
/* @__PURE__ */y/* @__NO_SIDE_EFFECTS__ */para restaurar la poda con una promesa manual.
Efectos secundarios ocultos
El caso difícil es una expresión de nivel superior cuyo resultado no se usa, pero que podría tener un efecto. Considera const tabla = crearTabla() en la raíz de un módulo, con tabla sin usar. ¿Puede el bundler eliminar la llamada? Solo si puede probar que crearTabla es pura. Si esa función vive en otro módulo, o toca algo que el análisis no ve, la respuesta es no: conservar la llamada, porque descartarla podría borrar un efecto —un registro, una escritura en un global, una mutación de un prototipo— del que alguien depende.
La pregunta que el bundler se hace ante cada expresión de nivel superior es siempre la misma: “¿puedo borrar esto sin que nadie lo note?”. Para la asignación de una constante pura, la respuesta es un sí seguro. Para una llamada cuyo cuerpo no tiene delante, la respuesta honesta es “no lo sé”, y “no lo sé” se resuelve, correctamente, conservando. El tree shaking no es tacaño; es escrupuloso, y su escrúpulo adopta la forma de dejar en pie todo aquello que no puede jurar que sobra.
// El bundler no sabe si crearWidget tiene efectos, asi que conserva la llamada
const w = crearWidget() // w no se usa, pero la llamada se mantiene
// Efecto oculto tras un getter: leer la propiedad ejecuta codigo
const valor = config.plugins // si plugins es un getter con efectos, no se poda
El caso del getter es especialmente instructivo porque parece inofensivo. Leer una propiedad se siente como una operación sin consecuencias, pero si esa propiedad la define un getter, leerla ejecuta código, y ese código puede hacer cualquier cosa. Por eso los bundlers ofrecen una opción para asumir que leer propiedades es siempre puro —acelera la poda— a cambio de una suposición que un getter con efectos violaría. Es un ejemplo perfecto de cómo cada agresividad extra del análisis se compra asumiendo algo sobre tu código.
Los focos habituales de efectos ocultos comparten un rasgo: el bundler no puede ver a través de ellos.
- Llamadas a funciones cuya pureza no puede demostrar, sobre todo si cruzan módulos.
- Getters y accesos a propiedades que ejecutan código al leerse.
- Mutaciones de prototipos o de objetos globales al evaluar el módulo.
- Referencias a globales desconocidos, que el bundler asume que pueden tener efectos.
El hilo que une todos estos casos es la incapacidad de probar la pureza. El bundler no conserva una expresión sin usar por pereza, sino porque no puede demostrar que eliminarla sea inocuo, y una demostración que no concluye se trata como un no. Cuanto más cruza una expresión las fronteras de módulo —llamando a funciones de otros archivos cuyo cuerpo el análisis no tiene delante— más se degrada esa capacidad de prueba, y más conservador se vuelve el resultado. La pureza es fácil de perder de vista y cara de certificar.
El import puro de efectos, que nunca se poda
Hay un import que el tree shaking jamás toca, y con razón: el import sin bindings, que existe únicamente por su efecto secundario. import './polyfills.js' o import './registrar-plugins.js' no traen ningún nombre; su propósito es que el módulo se evalúe. Podarlo sería contradecir la intención explícita del programador, así que se conserva siempre, al margen de cualquier análisis de exports.
import './polyfills.js' // se evalua por su efecto; nunca se poda
import './estilos-globales.css' // inyecta estilos; su efecto es el objetivo
import { debounce } from './utils.js' // este si se sujeta al analisis de uso
El contraste del ejemplo es la clave. Las dos primeras líneas no traen nombres, así que su única razón de existir es su efecto, y el bundler lo respeta sin discusión; la tercera trae un binding, así que entra en el juego normal del análisis de uso. Un mismo import cambia de categoría según traiga o no bindings, y esa distinción sintáctica —hay nombres a la izquierda del from o no los hay— es la que el bundler usa para decidir si un import es negociable o intocable.
Esto conecta con el sideEffects de la lección anterior: si un paquete declara sideEffects: false, incluso estos imports de efecto pueden eliminarse, porque el paquete ha prometido que no los tiene. De ahí que un import './setup' que desaparece en producción sea, casi siempre, un sideEffects mal declarado chocando con un import cuyo efecto era real.
Esta interacción entre el import de efecto y el sideEffects del paquete es de las más malinterpretadas. El import por efecto es sagrado por defecto —el bundler nunca lo toca— pero deja de serlo si el paquete que lo contiene ha jurado no tener efectos. Dicho de otro modo: import './polyfills' sobrevive salvo que el package.json diga sideEffects: false, en cuyo caso el bundler cree la promesa antes que a la intención aparente del import. Cuando un import de efecto desaparece, no es un fallo del tree shaking; es el tree shaking cumpliendo al pie de la letra un contrato mal firmado.
Algunas construcciones se sacuden mal no por lo que escribes, sino por lo que generan al transpilarse. Un enum no const de TypeScript baja a una IIFE que muta un objeto, y el bundler no la reconoce como pura. Los decoradores emiten llamadas a helpers como __decorate que parecen tener efectos. Ciertas clases con inicializadores estáticos producen código que el análisis no puede descartar. En estos casos el problema no está en tu import, sino en la forma del código generado, y la solución pasa por anotaciones de pureza que el propio transpilador puede emitir.
Las anotaciones de pureza
Cuando el autor sabe que una expresión es pura pero el bundler no puede probarlo, existe un puente: la anotación /* @__PURE__ */. Colocada justo antes de una llamada o una construcción, le promete al bundler que esa expresión no tiene efectos, de modo que si su resultado no se usa, puede eliminarse. La honran terser, esbuild, Rollup y el minificador de Oxc; es un convenio compartido por todo el toolchain.
El nombre de la anotación es feo a propósito: los dobles guiones bajos y las mayúsculas la vuelven improbable de teclear por accidente y fácil de reconocer para las herramientas. Es un comentario, así que no cambia la semántica del código si nadie lo lee; pero para el bundler que sí lo lee, es una directiva con consecuencias reales. Esa doble naturaleza —inerte para el lenguaje, significativa para la herramienta— es justo lo que le permite viajar dentro del código sin alterarlo.
// Sin la anotacion, la llamada se conserva por si acaso
export const iconos = /* @__PURE__ */ construirIconos()
// Helpers de transpilacion tambien la usan para seguir siendo podables
var resultado = /* @__PURE__ */ _decorate([...], Componente)
Su hermana más reciente es /* @__NO_SIDE_EFFECTS__ */, que se coloca antes de una declaración de función y promete que toda llamada a esa función es pura, sin tener que anotar cada invocación una por una. La adoptaron esbuild y Rolldown, y es la forma limpia de marcar una fábrica que se llama en muchos sitios: prometes una vez en la definición y el bundler poda cada llamada no usada.
La diferencia entre las dos anotaciones es de alcance y de ergonomía. /* @__PURE__ */ marca un sitio de llamada: si invocas la misma función en diez lugares, tendrías que anotar los diez. /* @__NO_SIDE_EFFECTS__ */ marca la definición: prometes una sola vez, en la función, y esa promesa se propaga a todas sus invocaciones. Para una fábrica de uso extendido la segunda es mucho más limpia; para una llamada aislada y puntual, la primera basta y sobra.
// @__NO_SIDE_EFFECTS__
export function crearTema(nombre) {
return { nombre, tokens: derivar(nombre) }
}
// cualquier `crearTema(...)` cuyo resultado no se use puede eliminarse
El uso idiomático de /* @__PURE__ */ no es esparcirlo a mano por tu código de aplicación, sino que lo emitan los generadores de código —Babel, TypeScript, un plugin— sobre construcciones que ellos saben puras. Cuando un transpilador baja un decorador o un enum a JavaScript, es él quien mejor sabe si el resultado tiene efectos, y las versiones modernas anotan esa salida para que siga siendo podable. Como autor de aplicación, tu tarea casi nunca es escribir estas anotaciones, sino no invalidarlas con un sideEffects deshonesto aguas arriba.
Efectos ocultos
Llamadas, getters y mutaciones cuyo efecto el bundler no puede descartar. Ante la duda, conserva.
import de efecto
Un import sin bindings existe por su efecto; nunca se poda, salvo que el paquete prometa sideEffects: false.
@__PURE__
Marca una llamada concreta como sin efectos, para que se elimine si su resultado no se usa.
@__NO_SIDE_EFFECTS__
Marca una función entera como pura en la definición; cada llamada suya se vuelve podable.
flowchart TD
A[expresion de nivel superior sin usar] --> B{el bundler prueba que es pura}
B -->|si| C[la elimina]
B -->|no la puede probar| D[la conserva por prudencia]
D --> E{hay anotacion de pureza}
E -->|@__PURE__ o @__NO_SIDE_EFFECTS__| C
E -->|sin anotacion| F[permanece en el bundle]
style C fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111bEl poder y su peligro
Las anotaciones invierten la carga de la prueba: donde el bundler exigía demostrar pureza, tú la afirmas. Y como toda afirmación que una herramienta cree sin verificar, es peligrosa si mientes. Un /* @__PURE__ */ sobre una llamada que sí tiene efectos hará que el bundler la elimine cuando su resultado no se use, borrando ese efecto en producción con el mismo sigilo que un sideEffects mal declarado. La anotación es una promesa, no una orden inocua.
La palabra que la define es inversión. Sin anotación, la carga de la prueba recae en el bundler, que debe demostrar la pureza para poder eliminar, y ante la duda conserva. Con anotación, esa carga se invierte: tú afirmas la pureza, y el bundler elimina fiándose de tu palabra. Es una herramienta de precisión, no un martillo, y su potencia nace justamente de que rompe el conservadurismo del análisis en el punto exacto donde tú posees un conocimiento que él no alcanza.
Por eso su uso idiomático no es esparcirlas por tu código de aplicación, sino que las emitan las herramientas que generan código —Babel, TypeScript, un plugin— sobre construcciones que ellas saben puras: los helpers de decoradores, ciertos enums, las fábricas que producen. Como autor de librería, las usas con cirugía sobre expresiones que has verificado; como autor de aplicación, casi siempre te llegan ya puestas y tu trabajo es no romperlas con un sideEffects deshonesto.
Repara en la simetría con la lección anterior. sideEffects es una promesa a nivel de módulo —“importar este archivo no tiene efectos”—; /* @__PURE__ */ es la misma clase de promesa a nivel de expresión —“evaluar esta llamada no tiene efectos”—. Ambas trasladan la optimización del terreno de la demostración al de la confianza, y ambas castigan la mentira con un bug de producción sin traza. Aprender una es, en el fondo, aprender la otra, porque comparten la misma estructura: el humano sabe algo que el análisis no puede deducir, y lo declara bajo su responsabilidad.
Piensa en lo que ocurre si marcas con /* @__PURE__ */ una llamada que registra un plugin o incrementa un contador global. En cuanto su resultado quede sin usar, el bundler la borrará, y el efecto que dependía de esa llamada no sucederá. No hay error, no hay aviso, no hay diferencia visible en el fuente: solo un comportamiento que falta. Por eso estas anotaciones no se ponen “por si acaso” ni se copian de un ejemplo sin entenderlas; se ponen cuando has verificado que la expresión es pura, con la misma seriedad con la que firmarías que lo es.
Aquí se toca una verdad de fondo sobre cualquier análisis estático, y el tree shaking la ilustra con precisión de laboratorio. Preguntar si evaluar una expresión arbitraria tiene efectos observables es, en su forma general, un problema indecidible: no existe ni existirá un analizador que lo resuelva para todo programa posible. Ante esa muralla, un análisis correcto no tiene más opción que ser conservador, y ser conservador significa, por definición, dejar código sobre la mesa: conservar cosas que podrían eliminarse, porque no puede probar que sea seguro hacerlo. Cada límite del tree shaking que has visto —la llamada de pureza incierta, el getter opaco, el enum que compila a una IIFE, el global desconocido— es una instancia de esa misma renuncia: el bundler prefiere un bundle un poco más grande y correcto a uno más pequeño y roto. Esa es la elección sabia por defecto, y no querrías que fuera otra. Pero deja una brecha entre lo que es seguro eliminar y lo que el análisis puede probar que es seguro eliminar, y esa brecha es puro conocimiento humano: el autor sabe que su fábrica es pura aunque el bundler no pueda deducirlo. Las anotaciones de pureza son el canal formal para volcar ese conocimiento en la herramienta. No son un truco ni un parche: son el reconocimiento explícito de que el análisis y el programador saben cosas distintas, y de que el segundo puede prestarle al primero una certeza que este no alcanza solo. La lección trasciende el bundling y reaparece en tipos, en concurrencia, en verificación: cuando una herramienta es conservadora por necesidad matemática, las anotaciones son cómo el humano le devuelve la agresividad sin sacrificar la corrección —siempre que el humano no mienta. Y ahí está el filo. Una anotación es un préstamo de confianza: el bundler poda porque tú has jurado que es seguro, y si tu juramento es falso, el fallo aparecerá lejos, en producción, desprovisto de un error que lo señale. Quien entiende esto trata cada /* @__PURE__ */ con la solemnidad de una firma y no con la ligereza de un comentario, porque en el fondo eso es: una firma que reactiva una optimización que la prudencia había, con toda la razón, apagado.
- Escribe una
constde nivel superior que llame a una función de otro módulo, no uses el resultado y confirma que la llamada sobrevive al build. - Añade
/* @__PURE__ */antes de esa llamada y verifica que ahora desaparece; explica qué le has prometido al bundler. - Compila un
enumnoconstde TypeScript y observa la IIFE que genera; razona por qué el análisis no la reconoce como pura. - Marca una función fábrica con
/* @__NO_SIDE_EFFECTS__ */y comprueba que sus llamadas no usadas se podan sin anotar cada una. - Miente a propósito con un
/* @__PURE__ */sobre una llamada con efecto y observa cómo el efecto desaparece en producción: entiende por qué es un bug tan traicionero.