define-config pages
- define-config
- Getting started
Getting started
Give your tool a typed config file with a per-user layer under it, load both, and report every problem with its key path.
This page builds the config for a small tool called shop. Each user may keep a file in ~/.config/shop/, each project keeps one at its root, and the project's file wins where both set a key.
Install
You need Bun: the examples are TypeScript files that Bun runs directly, the config file included. Under Node.js 22.14 the loader cannot import a .ts config file and reports it as a load-failed diagnostic, while .mjs, .js and .json config files load there.
The examples check the config with Zod 4. Any validator that implements Standard Schema V1, a shared interface for validation libraries, works in its place.
$ npm install @open-tomato/define-config zodDescribe the config and the layers
config.ts holds three things: the schema, the type that follows from it, and a loader. The loader tries the lookup names in each layer's folder, in order, and keeps the first file it finds.
// config.ts
import { homedir } from 'node:os';
import { join } from 'node:path';
import { z } from 'zod';
import { createLoader } from '@open-tomato/define-config';
export const schema = z.object({
name: z.string(),
port: z.number().int().positive(),
log: z.object({ level: z.enum(['debug', 'info', 'warn', 'error']) }),
});
export type Config = z.infer<typeof schema>;
export const loader = createLoader({
lookup: ['shop.config.ts', 'shop.config.json'],
layers: [
{ layer: 'user', dir: join(homedir(), '.config', 'shop') },
{ layer: 'project', dir: '.' },
],
schema,
});
The library knows no file names of its own: shop.config.ts is your choice. It reads .ts, .mts, .mjs, .js and .json files itself. For any other extension, such as YAML, you pass a reader in loaders.
Write the two files
The user's file is plain JSON, saved as ~/.config/shop/shop.config.json. JSON allows no comments, so the file holds only this line:
{ "port": 3000, "log": { "level": "info" } }
The project's file is code. defineConfig returns the array unchanged, and its type makes your editor flag a wrong key while you type:
// shop.config.ts
import { defineConfig } from '@open-tomato/define-config';
import type { Config } from './config';
export default defineConfig<Config>([
{ name: 'shop', log: { level: 'debug' } },
]);
A config file in .ts or .js is code, and it runs when the loader imports it. A schema checks the value it returns, after the code has run. Your tool decides which folders it trusts.
Load and report
load returns the merged config, every diagnostic, the files it read, and the provenance: a record of which entry set each key.
// load.ts
import { basename } from 'node:path';
import { provenanceOf } from '@open-tomato/define-config';
import { loader } from './config';
const result = await loader.load(process.cwd());
console.log(result.config);
console.log(result.diagnostics);
console.log(result.sources.map((s) => `${s.layer}: ${basename(s.path)}`));
const last = provenanceOf(result, 'log.level').at(-1);
console.log('log.level was last set by', last);
if (result.diagnostics.some((d) => d.level === 'error')) {
process.exit(1);
}
Run it from the project folder with bun load.ts:
{
port: 3000,
log: {
level: "debug",
},
name: "shop",
}
[]
[ "user: shop.config.json", "project: shop.config.ts" ]
log.level was last set by {
entry: 1,
layer: "project",
kind: "set",
}
The port came from the user's file, and the log level from the project's file, which is entry 1 in layer project. The last record of a path names the entry that set it last.
When a value is wrong
Change the project file to set the port as a string, and drop the <Config> type so the editor lets it through:
// shop.config.ts
import { defineConfig } from '@open-tomato/define-config';
export default defineConfig([
{ name: 'shop', port: '8080' },
]);
The load still returns a config, and the schema's complaint arrives as a diagnostic with a code and a path:
[
{
level: "error",
code: "schema",
path: [ "port" ],
message: "Invalid input: expected number, received string",
}
]
load.ts exits with code 1 here, because its last lines turn any error into a failed run. With defineConfig<Config> kept, TypeScript reports the same mistake in the editor, before anything loads: Type 'string' is not assignable to type 'number'.
Coming, in an open spec (define-config#39, a config file that exports a function): a config file will be able to default-export a function, sync or async, that returns entries, the way Vite's and ESLint's config files can. It is not built in 0.5.0.
Next
Layers and merge rules adds a team layer, and shows how a project removes or replaces what the team set.