Important: Keep AGENTS.md updated with project info.
c12 is a smart configuration loader for JavaScript/TypeScript projects. It loads config from multiple sources (files, RC, package.json, env, remote), merges them with a defined priority, and supports watching/HMR.
src/
├── index.ts # Public exports barrel
├── types.ts # All TypeScript type definitions
├── loader.ts # Core config loading logic (~450 LoC)
├── watch.ts # Native `node:fs` watcher with HMR support (~400 LoC)
├── dotenv.ts # .env file parsing and interpolation (~235 LoC)
└── update.ts # Programmatic config file creation/update (~130 LoC)
test/
├── loader.test.ts # Config loader tests (fixture-based)
├── dotenv.test.ts # Dotenv parsing/interpolation tests
├── update.test.ts # Config update tests
├── watch.test.ts # watchConfig tests (temp dir)
└── fixture/ # Real config files for testing
Two build entry points: ./dist/index.mjs (main) and ./dist/update.mjs (update utilities).
- Config overrides (
overridesoption) - Main config file (
{name}.config.{ext}) - RC files (
.{name}rc— local, workspace, home) package.jsonconfig key- Default config (
defaultsoption) - Extended layers (
extendskey, recursive)
- Lazy loading — Optional deps (
giget,jiti,dotenv,magicast) imported on demand - Deep merge via
defu— Layers are merged bottom-up withdefu - Environment overrides —
$test,$development,$productionkeys auto-applied based on env - Remote extends — Config can extend from GitHub/GitLab/npm via
giget - Layer dedupe — Each extended layer is loaded once per
loadConfigcall (first occurrence wins), keyed by config file real path, giget source string, or resolverconfigFile/cwd; opt out withextend: { dedupe: false } - Dynamic config — Supports
export default (ctx) => ({ ... })functions - Schema validation — Optional
schema(Standard Schema) validates the final merged config once; output replacesconfig - ESM cache busting — Native
import()with incrementing query string_${++counter}; on Bun (which ignores the query) the file'srequire.cacheentry is also deleted
| Package | Purpose |
|---|---|
confbox |
Parse YAML, JSONC, JSON5, TOML (lazy) |
defu |
Deep merge config layers |
exsolve |
Module path resolution |
pathe |
Cross-platform path utilities |
pkg-types |
package.json reading |
rc9 |
RC file parsing |
| Package | When needed |
|---|---|
giget |
Remote git extends |
jiti |
Legacy/CJS TypeScript loading |
dotenv |
.env parsing on Node <20.6 |
magicast |
updateConfig() AST modification |
loadConfig<T>(options); // Core: load and merge config from all sources
watchConfig<T>(options); // Watch config files, reload on change
loadDotenv(options); // Load .env files into an object
setupDotenv(options); // Load .env files into process.env
updateConfig(options); // Create or update config files (experimental)
createDefineConfig<T>(); // Type-safe config definition helper
SUPPORTED_EXTENSIONS; // Array of all supported file extensions- Build:
obuild - Test:
vitest(run withpnpm vitest run <path>) - Lint:
oxlint+oxfmt(Rust-based) - Types:
tsc --noEmit(pnpm test:types) - Package manager:
pnpm
pnpm build # Build dist/
pnpm test # Lint + test + type check
pnpm vitest run test/loader.test.ts # Run specific test
pnpm dev # Vitest watch mode
pnpm lint:fix # Auto-fix lint + format- Fixture-based: real config files in
test/fixture/ transformPaths()helper normalizes absolute paths for snapshots- Dotenv tests use temp directories with
beforeEach/afterAllcleanup - Inline snapshots preferred
- ESM-only, no CommonJS
- Explicit
.ts/.jsextensions in imports - No barrel files (except
index.tsentry) - Internal helpers at bottom of files
- Options object pattern for multi-arg functions