wandres.dev
TURBOREPO · task graph y caching

inputs y outputs: la frontera de una tarea

Para que la caché sea correcta, Turborepo necesita saber dos cosas de cada tarea: qué archivos la alimentan y qué archivos produce. inputs define el dominio del hash; outputs define qué se guarda y se restaura en un acierto. Aquí se estudia el conjunto de entradas por defecto, el token TURBO_DEFAULT, las negaciones, y por qué una frontera mal declarada rompe el caching de las dos maneras posibles.

⏱ 16 min

Una tarea, para poder memoizarse, tiene que declarar honestamente su frontera con el mundo: por dónde entra la información que la determina y por dónde sale el resultado que produce. Turborepo llama a esas dos caras inputs y outputs. Los inputs son el dominio de la función —lo que se resume en el hash— y los outputs son su imagen —lo que se archiva y se restaura cuando el hash coincide—. Declararlos bien es lo que separa una caché rápida y correcta de una que reutiliza artefactos rancios o que nunca acierta.

🎯 Al terminar esta lección sabrás
  • Declarar outputs con globs y con negaciones para excluir subcarpetas de caché.
  • Entender el conjunto de inputs por defecto y cómo extenderlo con $TURBO_DEFAULT$.
  • Ver inputs como el dominio del hash y outputs como lo que se archiva.
  • Diagnosticar los dos fallos de una frontera mal declarada: falsos aciertos y falsos fallos.

outputs: qué se guarda y se restaura

Cuando una tarea se ejecuta con éxito, Turborepo empaqueta los archivos que casan con outputs y los guarda junto al hash de esa ejecución. En un acierto de caché posterior, en vez de correr el script, restaura esos archivos en su sitio. Si outputs no incluye algo que la tarea produjo, ese algo no se guardará y, tras un acierto, faltará en disco como si nunca se hubiera construido.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "!dist/**/*.map"]
    },
    "build:next": {
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

Las negaciones con ! recortan del conjunto ya elegido, y su caso canónico es excluir cachés internas del propio framework: guardar .next/cache/** en la caché de Turborepo sería archivar una caché dentro de otra, inflando el artefacto sin motivo. La regla .next/** con !.next/cache/** es casi un patrón fijo para Next.js.

Una tarea puede declarar varias carpetas de salida: los globs de outputs se acumulan y todo lo que casen se archiva junto bajo el mismo hash. Lo que no aparezca en ningún glob no viaja a la caché y, tras un acierto, no se restaura. Los globs se interpretan siempre desde la raíz del paquete, no del repositorio, así que dist/** en @acme/ui significa packages/ui/dist/**.

⚠️
outputs vacío no significa ninguna salida, significa no guardar nada

Si una tarea produce archivos pero olvidas declararlos en outputs, Turborepo la cacheará igual —registrará su hash y sus logs— pero con cero artefactos. El síntoma es desconcertante: la segunda ejecución dice acierto de caché y termina al instante, pero tu dist/ está vacío porque no había nada guardado que restaurar. Una tarea que emite archivos y no declara outputs es casi siempre un bug.

inputs: el dominio del hash

Los inputs responden a la pregunta inversa: qué archivos, si cambian, deben invalidar el resultado. Por defecto Turborepo es conservador y seguro: sin inputs declarados, el conjunto de entradas de una tarea es todos los archivos del paquete rastreados por git, excluyendo lo ignorado por .gitignore. Es la opción prudente porque nunca deja fuera algo que importe; su coste es que un cambio irrelevante —un README.md— invalida la caché.

Declarar inputs explícitos afina ese dominio, restringiéndolo a lo que de verdad determina la salida:

{
  "tasks": {
    "build": {
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "test": {
      "inputs": ["$TURBO_DEFAULT$", "!**/*.md"]
    }
  }
}

Aquí aparece el matiz que más gente confunde. Cuando escribes una lista de inputs, reemplazas por completo el conjunto por defecto: solo se hashea lo que enumeraste. Eso es potente pero peligroso, porque si olvidas un archivo que sí influye —un .env.production, un archivo de configuración— la tarea seguirá acertando en caché aunque ese archivo cambie, y estarás sirviendo un artefacto viejo.

$TURBO_DEFAULT$ y el equilibrio preciso

Para no tener que elegir entre “todo el paquete” y “una lista que quizá olvide algo”, Turborepo ofrece el token $TURBO_DEFAULT$, que se expande al conjunto por defecto —todos los archivos rastreados— y te deja ajustarlo con adiciones y negaciones en lugar de reemplazarlo entero.

{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", "!**/*.test.ts", "!**/*.stories.tsx"]
    }
  }
}

Leído: parte del conjunto seguro por defecto, pero no dejes que los archivos de test ni las historias de Storybook invaliden el build, porque no acaban en el dist/. Es la forma correcta de excluir ruido sin arriesgarte a olvidar algo relevante: sigues incluyendo todo por defecto y solo restas lo que sabes que no influye. La diferencia con una lista manual es de dirección —restas de lo completo en vez de sumar desde cero— y esa dirección es justo la que evita los falsos aciertos.

Regla práctica para elegir la forma: usa $TURBO_DEFAULT$ con negaciones cuando quieras quitar ruido conocido del conjunto seguro —tests, historias, documentación— y reserva la lista manual y explícita solo para tareas cuyo dominio conoces con total certeza y es pequeño y estable. En la duda, la forma con $TURBO_DEFAULT$ es siempre la más segura, porque su punto de partida es todo el paquete y solo resta lo que demuestres irrelevante.

flowchart LR
src[archivos src] --> hash[hash de la tarea]
cfg[package json y tsconfig] --> hash
env[variables env declaradas] --> hash
deps[hashes de dependencias internas] --> hash
hash --> tarea[ejecutar o restaurar]
tarea --> out[outputs archivados]
style hash fill:#f9e2af,color:#11111b
style tarea fill:#89b4fa,color:#11111b
style out fill:#a6e3a1,color:#11111b

El diagrama deja ver que inputs no es lo único que entra en el hash —también las variables de entorno declaradas y los hashes de las dependencias internas, que veremos en la lección de caching— pero sí es la parte que tú gobiernas archivo a archivo. Los outputs, en cambio, viven al otro lado: no influyen en el hash, definen qué se archiva.

Hay un caso límite instructivo: una tarea sin archivos de entrada propios, como un deploy que solo orquesta. Su hash dependerá de sus dependencias y su entorno, no de código fuente, y eso es correcto, porque su salida no la determina ningún archivo del paquete. Pensar inputs como el dominio causal de la salida, y no como los archivos del paquete, es lo que te deja razonar bien estos casos raros.

Cuando la frontera miente

Toda la corrección del caching descansa en que esta frontera sea fiel. Hay exactamente dos formas de mentir, simétricas y de gravedad opuesta.

🟥

inputs demasiado estrechos

Falta un archivo que sí influye. Turborepo acierta en caché cuando no debía y restaura un artefacto rancio. Es un bug de correctitud: silencioso, difícil de rastrear, capaz de desplegar código viejo.

🟨

inputs demasiado amplios

Entran archivos que no influyen. Turborepo falla en caché cuando podría haber acertado y reconstruye de más. Es un bug de rendimiento: molesto pero seguro, nunca sirve algo incorrecto.

La asimetría dicta la estrategia. Ante la duda, peca de amplio: un falso fallo cuesta segundos de recompute, un falso acierto cuesta un despliegue equivocado y horas de depuración. Por eso el conjunto por defecto es “todo el paquete” y por eso $TURBO_DEFAULT$ existe: te empujan hacia el lado seguro. Solo estrechas inputs cuando puedes demostrar que un archivo no afecta a la salida, no cuando lo intuyes.

El caso real más frecuente de inputs demasiado estrechos: una tarea de build que lee un archivo generado fuera de src/ —un schema.graphql, un .env compilado, una config emitida por otra herramienta—. Si tu lista decía ["src/**"], ese archivo nunca entra en el hash, y regenerarlo no invalida el build; el artefacto restaurado ignora el nuevo contrato y despliegas código que apunta a un esquema viejo. El arreglo no es desconfiar de la caché, sino ampliar la frontera: añade el archivo a inputs o, mejor, parte de $TURBO_DEFAULT$ para que entre por defecto.

📝
Para cruzar la frontera del paquete, usa globalDependencies

Como los globs de inputs son relativos al paquete, es tentador escribir rutas relativas hacia arriba para depender de una config de la raíz. No lo hagas: para archivos compartidos que viven fuera del paquete —un tsconfig.base.json, un .env de raíz— está globalDependencies, el mecanismo pensado justo para dependencias que cruzan la frontera. Entra en el hash de todas las tareas, que es exactamente lo que quieres para un archivo que afecta a todo el monorepo.

💡
El outputs mal declarado se detecta con dos ejecuciones y un borrado

Para verificar que outputs captura todo lo que la tarea produce: ejecuta la tarea, borra a mano su carpeta de salida, y vuelve a ejecutar. Si el segundo intento acierta en caché y restaura la carpeta completa, tu outputs es fiel. Si restaura una versión incompleta, te falta algún glob. Es la prueba más rápida para no descubrir el hueco en producción.

inputs y outputs son el contrato que hace legítima la memoización

Detrás de estos dos campos hay una idea de fondo que trasciende Turborepo: memoizar una función solo es correcto si conoces con exactitud su dominio y su imagen. Una función pura queda determinada por sus entradas; si te dejas una entrada fuera del cálculo del hash, tratas como pura una función que no lo es, y la memoización pasa de optimización a fuente de bugs. inputs es tu declaración del dominio —el compromiso de que nada fuera de ese conjunto altera el resultado— y outputs es tu declaración de la imagen —el compromiso de que todo lo que la tarea produce cabe en esos globs—. Turborepo no puede verificar ninguno de los dos por ti: no sabe leer tu script para deducir qué archivos toca, así que confía en tu palabra y la codifica en el hash. De ahí que los dos modos de fallo sean, en el fondo, dos formas de romper un contrato: inputs incompletos rompen la promesa del dominio y producen falsos aciertos; outputs incompletos rompen la promesa de la imagen y producen restauraciones parciales. La consecuencia práctica es una disciplina que un senior interioriza: cada vez que una tarea empieza a leer una fuente nueva —un archivo de entorno, un recurso generado, una config compartida— hay que preguntarse si el hash lo ve, y cada vez que empieza a emitir un artefacto nuevo, si outputs lo captura. La caché no es una capa mágica que se pone encima; es un contrato que tú firmas cada vez que declaras la frontera de una tarea, y su corrección no supera nunca la honestidad de esa firma.

⚔️ Declara la frontera con honestidad
  1. Da a build unos outputs: ["dist/**"], ejecútala, borra dist/ y reejecuta: confirma que la restauración desde caché deja dist/ completo.
  2. Quita outputs, repite el borrado y observa que el acierto de caché ya no restaura nada.
  3. Reemplaza los inputs por una lista manual que omita un archivo real de src/, cámbialo y comprueba que la tarea sigue acertando en caché indebidamente.
  4. Corrige el punto anterior usando ["$TURBO_DEFAULT$", "!**/*.md"] y verifica que ahora un cambio en el código sí invalida, pero un cambio en un .md no.
  5. Añade !.next/cache/** a una tarea de Next.js y compara el tamaño del artefacto cacheado antes y después.