wandres.dev
TURBOREPO · task graph y caching

turbo.json: tasks, dependsOn y topología

El turbo.json es la especificación del grafo de tareas. Cada clave de tasks es un tipo de tarea; dependsOn declara sus precedencias. El operador ^ significa primero las dependencias, y es lo que traduce la topología del monorepo a una sola línea. Aquí se aprende a leer y escribir esa gramática: same-package, topológico, nodos concretos, y la configuración global.

⏱ 17 min

Si el grafo de tareas es la idea, turbo.json es donde la escribes. No enumeras nodos uno a uno —serían cientos— sino reglas que Turborepo expande sobre el grafo de paquetes para generar el grafo de tareas concreto. La pieza central es dependsOn, y dentro de ella un solo carácter, el circunflejo ^, carga con casi toda la semántica: distingue entre necesitar la misma tarea en las dependencias, la misma tarea en el propio paquete, o un nodo específico. Dominar esa gramática es dominar Turborepo.

🎯 Al terminar esta lección sabrás
  • Escribir la clave tasks de turbo.json y sus campos por tarea.
  • Interpretar dependsOn con ^build, con build a secas y con nodos concretos.
  • Comprender cómo esas reglas se expanden en el grafo de tareas real.
  • Configurar el ámbito global con globalDependencies, globalEnv y las config por paquete.

Anatomía de tasks

Un turbo.json mínimo declara un $schema para autocompletado y un objeto tasks. Cada clave de tasks es el nombre de una tarea —el mismo que en los scripts del package.json— y su valor describe cómo se comporta esa tarea en todos los paquetes que la definan.

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
⚠️
En Turborepo 2.x la clave es tasks, no pipeline

Si vienes de configuraciones antiguas o de tutoriales viejos verás pipeline en la raíz. Ese nombre se renombró a tasks en Turborepo 2.0 y pipeline ya no se acepta en 2.x. El mismo cambio de versión renombró outputMode a outputLogs. Si copias un turbo.json de 2023 y no compila, empieza por ahí.

Los campos por tarea más frecuentes: dependsOn fija las precedencias; outputs declara qué artefactos produce —lo verás a fondo en la próxima lección—; cache desactiva la memoización para tareas que no la admiten; persistent marca procesos de larga duración —un servidor de dev— que no terminan y de los que nadie puede depender; env lista las variables de entorno que influyen en el resultado; y outputLogs controla cuánta salida se imprime en un acierto de caché.

El operador circunflejo: topología en una línea

Aquí está el corazón. dependsOn toma una lista de referencias a tareas, y el prefijo cambia radicalmente su significado.

🔺

^build (topológico)

El circunflejo significa primero las dependencias. ^build dice: antes de mi build, corre el build de todos los paquetes de los que dependo, recursivamente. Es la traducción directa del orden topológico.

🏠

build (mismo paquete)

Sin prefijo, la referencia apunta a otra tarea del mismo paquete. test con dependsOn: ["build"] exige que este paquete se construya antes de testearse, sin decir nada de los demás.

📍

paquete#tarea (nodo concreto)

Con nombre completo apuntas a un nodo específico del grafo. @acme/db#generate fuerza que ese nodo exacto corra antes, útil para un paso singular del que dependen muchos.

La regla "build": { "dependsOn": ["^build"] } es, con diferencia, la más importante de todo Turborepo. Leída en voz alta: para construir cualquier paquete, construye primero aquello de lo que depende. Con esa única línea, Turborepo recorre el grafo workspace: que le da pnpm y genera todas las aristas topológicas del grafo de tareas, sin que tú enumeres ninguna.

flowchart TD
utb[utils build] --> uib[ui build]
utb --> apib[api build]
uib --> webb[web build]
apib --> webb
webb --> webt[web test]
style utb fill:#f9e2af,color:#11111b
style uib fill:#a6e3a1,color:#11111b
style apib fill:#a6e3a1,color:#11111b
style webb fill:#89b4fa,color:#11111b
style webt fill:#cba6f7,color:#11111b

En ese grafo, web#build no arranca hasta que terminan ui#build y api#build —sus dependencias vía ^build—, y web#test espera a web#build por la regla test de mismo paquete. Turborepo dibujó todo eso a partir de dos reglas y del grafo de paquetes. Añade mañana un paquete nuevo entre medias y las aristas se recalculan solas: nunca codificas secuencias a mano.

Mismo paquete, nodos concretos y persistentes

La distinción entre ^build y build es sutil y decide correctitud. dependsOn: ["build"] en la tarea test no fuerza a construir las dependencias, solo el propio paquete; si tus tests importan el dist/ de un paquete vecino, necesitarás ["build", "^build"] para exigir ambos. Combinar prefijos en la misma lista es idiomático y frecuente.

{
  "tasks": {
    "test": { "dependsOn": ["build", "^build"] },
    "deploy": { "dependsOn": ["build", "test", "lint"] },
    "db:migrate": { "cache": false },
    "typecheck": { "dependsOn": ["^typecheck"] }
  }
}

Las tareas persistentes merecen cuidado: un servidor de desarrollo no termina, así que marcarlo persistent: true le dice a Turborepo que no espere su fin y que prohíba a otras tareas depender de él —depender de algo que nunca acaba bloquearía el grafo—. Para acompañar un persistente con tareas auxiliares está el campo with, que las corre en paralelo sin crear una arista de precedencia.

📝
cache false y persistent no son lo mismo

Es fácil confundirlos porque suelen ir juntos en dev. cache: false dice que la tarea no produce salida memoizable y por tanto se ejecuta siempre. persistent: true dice que la tarea no termina. Un servidor de dev es ambas cosas; un db:migrate es cache: false pero no persistente; un linter es cacheable y no persistente. Piensa cada campo por separado.

Configuración global y config por paquete

No todo cabe dentro de una tarea. Hay archivos y variables que afectan a todas las tareas por igual —un tsconfig.json de raíz, un .env, la versión de Node— y para eso está el ámbito global, que entra en el hash de absolutamente todos los nodos.

{
  "$schema": "https://turborepo.com/schema.json",
  "globalDependencies": ["tsconfig.base.json", ".env"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }
  }
}

Cambiar cualquier archivo de globalDependencies invalida la caché de todo el monorepo, porque se asume que puede afectar a cualquier tarea; por eso la lista debe ser corta y verdadera. Para reglas específicas de un paquete sin ensuciar la raíz, Turborepo admite un turbo.json propio dentro del paquete que extiende al de raíz con "extends": ["//"] y sobrescribe solo las tareas que necesite. El símbolo // designa la raíz del workspace, y también sirve para declarar tareas de raíz con la sintaxis //#tarea.

Un turbo.json de paquete solo menciona lo que cambia respecto a la raíz:

// packages/docs/turbo.json
{
  "extends": ["//"],
  "tasks": {
    "build": {
      "outputs": ["build/**"],
      "env": ["DOCS_BASE_URL"]
    }
  }
}

Con extends: ["//"], la tarea build de docs hereda el dependsOn: ["^build"] de la raíz pero sobrescribe sus outputs y añade una variable propia. Es la vía para que un paquete con necesidades peculiares no obligue a ensuciar la configuración común, y para mantener la raíz como el contrato mínimo que comparten todos.

Las tareas de raíz —las que no pertenecen a ningún paquete, como un formateo global o un chequeo de licencias— se declaran con el prefijo //# y corren una sola vez sobre el repositorio entero:

{
  "tasks": {
    "//#format": {
      "outputs": [],
      "cache": false
    }
  }
}
turbo.json es una gramática que se expande, no una lista que se enumera

La madurez con Turborepo llega cuando dejas de ver turbo.json como configuración y empiezas a verlo como un pequeño lenguaje declarativo cuya semántica es una expansión sobre el grafo de paquetes. No escribes el grafo de tareas: escribes las reglas de producción que lo generan, y Turborepo hace de intérprete. ^build no es un valor mágico, es un cuantificador —para todo paquete del que dependo, su tarea build— que se resuelve contra la topología que pnpm ya conoce; build a secas es otro cuantificador restringido al paquete actual; @acme/db#generate es una constante, un nodo nombrado. Esta naturaleza declarativa es exactamente lo que da robustez al sistema: como no enumeras aristas, no hay aristas que olvidar cuando el grafo crece, y como las reglas se reevaluan en cada ejecución, el orden se mantiene correcto por construcción ante cualquier refactor de dependencias. El coste de esta potencia es que un error en una regla se propaga a cientos de nodos en silencio —olvidar ^build en test no rompe nada visible hasta que un build en paralelo lee un dist/ a medio escribir— y por eso la disciplina correcta es pensar cada dependsOn como una afirmación universal sobre el grafo entero, no como una instrucción local. El turbo.json bien escrito es sorprendentemente corto: cinco o seis reglas capturan la topología de un monorepo de cien paquetes, porque cada regla habla de todos a la vez.

⚔️ Escribe la gramática del grafo
  1. Crea un turbo.json con build (^build y outputs: ["dist/**"]), test y lint, y verifica el grafo con turbo run build --dry-run.
  2. Cambia test para que dependa de ["build"] y observa en el --dry-run que aparece la arista de mismo paquete.
  3. Añade "^build" a test y razona qué caso de incorrectitud evita frente a la versión anterior.
  4. Marca dev como cache: false y persistent: true; intenta hacer que otra tarea dependa de dev y lee el error del grafo.
  5. Extrae un tsconfig.base.json a globalDependencies, tócalo y comprueba que invalida la caché de todas las tareas a la vez.