Skip to content
GitHubRSS

Config in TypeScript, and why there are no named inputs

Turborepo and Nx both grew the same features for the same reason. globalDependencies, globalEnv, namedInputs, targetDefaults, extends: every one of them is a way to say something once and reuse it, added because JSON cannot say anything once. vx’s config is a TypeScript module, so the feature list is a language feature list, and it is already complete.

packages/app/vx.config.ts
import { defineProject } from '@vzn/vx'
export default defineProject({
tasks: {
build: {
exec: { command: 'tsc -b' },
cache: {
inputs: { files: ['src/**', 'tsconfig.json'] },
outputs: { files: ['dist/**'] },
},
},
test: {
dependsOn: ['build'],
exec: { command: 'vitest run' },
cache: { inputs: { files: ['src/**', 'test/**'] }, outputs: { files: [] } },
},
},
})

defineProject is an identity function — it returns its argument — that exists for autocomplete and validation. The files vx init and @vzn/vx-migrate generate skip even that and write satisfies ProjectConfig with a type-only import: the same checking without a runtime import of core in every config file, which is a second copy of core loaded per run, ~17 ms on a two-package workspace.

A shared preset is a file:

// vx-preset.ts (workspace root)
export const lib = (entry: string) => ({
build: {
exec: { command: `tsup ${entry}` },
cache: { inputs: { files: ['src/**', 'tsup.config.ts'] }, outputs: { files: ['dist/**'] } },
},
})
packages/ui/vx.config.ts
import type { ProjectConfig } from '@vzn/vx'
import { lib } from '../../vx-preset.ts'
export default { tasks: { ...lib('src/index.ts') } } satisfies ProjectConfig

Because the key hashes the resolved config, an edit to vx-preset.ts re-keys every task that spread it. There is no list of global dependencies to maintain, because the dependency is the import, and the runtime already tracks imports.

The same shape covers what namedInputs and targetDefaults do. A named input is a constant. A target default is a function that returns a task and takes the parts that vary. Nx’s extends is ...spread. All of them are features you already know from the language, checked by the compiler, refactorable by the editor.

A program can do too much, and two things are refused rather than allowed:

  • Reading another project. Globs are resolved inside the project directory. ../shared/** is an error, and cache.inputs.workspaceFiles is the one named exception for files at the workspace root.
  • Being impure without saying so. A config that names process, Date, fetch, import.meta, a dynamic import() or any of the other globals on the purity gate’s list is evaluated live every run and never served from the evaluation cache. That is the correct behaviour, not a penalty; it just means a config that reads process.env should be one that has to.

When “evaluated live” is exactly what a CI pipeline must not do, vx lock evaluates every config once and writes the resolved objects to vx-lock.json; vx run --frozen consumes the lock with no evaluation at all, and --check catches drift a byte hash cannot see.

vx init writes these files from package.json scripts. bunx @vzn/vx-migrate writes them from a turbo.json or an Nx project graph, and when it meets a globalDependencies list it generates the preset file and the import for you, because that is what the list was trying to be. The schema reference is Configuration schema.