turbo.json task graph
why
task order in tribal knowledge and root convenience scripts gives the repository no enforceable lifecycle: stale artifacts pass as fresh, uncacheable steps get cached, and every consumer guesses whether to build a dependency first. modeling real input/output edges, outputs, and boundaries makes the pipeline a checked declaration, not a remembered sequence.
rule
Turbo is the repository lifecycle graph, not a command shortcut: which outputs exist, what runs first, what stays uncached, which package boundaries are illegal.
the root is sacred: its package.json coordinates Turbo only — no application dependencies, package tooling, or convenience scripts. standard full pipeline:
pnpm install && turbo run generate format build lint typecheck
pnpm installestablishes the exact lockfile graph before Turbo runs package tasks.generate,format,build,lint,typecheckare stable lifecycle names.- add a task only with a real package owner and a verified output or failure boundary.
model facts, not command order
a task edge must express a real input/output dependency: ^build means the consumer reads a dependency's built surface; a local generate dependency means the task reads generated files from its own package. never add an edge just because the command usually runs first — it must protect a real fact that would otherwise be stale or absent.
| task | inputs it may rely on | output/cache policy |
|---|---|---|
generate | authored schemas, route definitions, generator configuration | uncached when it writes derived artifacts; outputs are declared where supported. |
format | authored source and the package's formatter configuration | no shared root command; it formats only the owning package. |
build | local generated files and dependency build surfaces | depends on generate and ^build; cache only declared build output. |
lint | authored source and the package's lint configuration | package-local diagnostic boundary; cache only when inputs/config are declared. |
typecheck | authored types, generated declarations, dependency public types | depends on the producer phase that makes those inputs available. |
dev | current source and local generators | persistent and uncached. |
deploy | verified build output and environment configuration | uncached and owned by the deployable package. |
required tasks
generate: uncached, owns generated outputs.format/lint: run through the owning package script, never a shared root script.build: depends on local generation and^build; declares real outputs.typecheck: depends on build when generated declarations or built surfaces are inputs.dev/deploy: uncached;devis persistent.
do not
- let a consumer build before the producer's generation task.
- cache deployment or generated output without a reviewed reason.
- encode package ownership in opaque root scripts.
- add a root ESLint, formatter, or application-tool script for package work.
- let a package omit its lifecycle script and rely on a root fallback.
decision process
a consumer failing on a missing generated type is not fixed by a second generator command in the consumer. trace the missing file to its owner, make that producer's generate task explicit, then make the consumer depend on the producer's public build surface. this keeps local development and remote cache aligned instead of relying on a developer's command history.
root and package split
| location | allowed responsibility | forbidden responsibility |
|---|---|---|
root package.json | package manager declaration, workspace metadata, and Turbo orchestration | application/library dependencies, per-package ESLint or formatter scripts, and convenience fan-out aliases. |
each workspace package.json | its own generate, format, build, lint, typecheck, dev, and deploy scripts when applicable | invoking another workspace's private source or configuration. |
package-local config/ folder | that package's ESLint/formatter configuration when it would clutter its package root | becoming an undeclared repository-wide config owner. |
every package chooses and versions its own lint and format configuration — at its root or in a local config/ folder — and its own scripts invoke it. never centralize ESLint or formatting into root scripts or a hidden shared fallback.
good and bad
bad
// root package.json - violates ownership{"scripts": {"lint:all": "eslint 'apps/**/*' 'packages/**/*' --config root-eslint.js","build:frontend": "cd apps/frontend && npm run build","build:backend": "cd apps/backend && npm run build"}}
// apps/backend/package.json - missing dependency{"scripts": {"build": "tsc" // no generate dependency}}
lint:all reaches into every workspace with one shared ESLint config; the consumer's build omits the generate dependency because the developer's shell history happened to run generation first — remote cache hits now build against absent generated files.
good
// root package.json - coordinates only{"scripts": {"install": "pnpm install","full": "turbo run generate format build lint typecheck"}}
// apps/backend/package.json - owns its lifecycle{"scripts": {"generate": "drizzle-kit generate","build": "turbo run build --filter=... --deps=$.^build","lint": "eslint 'src/**/*' --config eslint.config.js"}}
// turbo.json - declares real edges{"tasks": {"build": {"dependsOn": ["^build", "generate"],"outputs": [".next/**", "dist/**"]},"generate": {"cache": false,"outputs": ["generated/**"]}}}
each package owns its generate, lint, and build scripts with its own config; build declares generate and ^build as inputs, so a clean clone and the remote cache match local development.
verification
run the graph from a clean derived-output state. confirm: generation precedes every consumer that needs it; a second unchanged build restores from the intended cache; dev/deploy are never reported as cacheable.