c12

Smart configuration loader for JavaScript and TypeScript that merges config files, RC files, package.json fields, dotenv, and remote extends into one resolved config.

Library
npm
v4.0.0-rc.1
895stars
MIT License

Repository Health

Pre-computed score based on development activity, maintenance, community, maturity, and trend momentum.How we score it →
77/100Good
Development Activity80
Maintenance80
Community52
Maturity56
Momentum40

Technical Analysis

AI-assessed by reading the actual repository — architecture, code quality, innovation, and documentation.How we score it →
78/100Good
Architecture85
Code Quality84
Innovation88
Learning Curve55

c12 is a configuration loading library for JavaScript and TypeScript projects, built by the UnJS team behind Nuxt, Nitro, and other tools. It resolves a project’s config by merging multiple sources — an explicit config file, RC files, package.json fields, environment variables, and remote layers — using a fixed priority order, so callers get one deeply-merged object regardless of which combination of sources exists.

Beyond simple merging, c12 supports config “extends” chains that can pull in local directories or remote GitHub/GitLab/npm sources via giget, environment-specific overrides through reserved $test/$development/$production/$env keys, and a watchConfig mode that hot-reloads consumers when any of the resolved files change. Version 4 significantly reduced install size and cold-start time by trimming c12’s dependency graph and moving several loaders (chokidar, giget, jiti, dotenv, magicast) to optional peer dependencies that are only pulled in when a project actually uses that feature.

What You Get

  • Multi-source config resolution - merges overrides, main config file, RC files, package.json fields, and defaults in a fixed, documented priority order.
  • Format-agnostic file loading - loads .js/.ts/.mjs/.cjs/.mts/.cts via native import (with jiti fallback) plus .json/.jsonc/.json5/.yaml/.yml/.toml via confbox parsers.
  • Config extends chains - recursively extend from local paths or remote GitHub/GitLab/bitbucket/npm sources through giget, with per-layer options.
  • Environment-specific overrides - reserved $test, $development, $production, and $env keys are applied automatically based on envName.
  • Live config watching - watchConfig reloads consumers on file add/change/remove with onWatch/acceptHMR/onUpdate hooks.
  • Programmatic config updates - updateConfig (via magicast) creates or edits config files as an AST rather than raw text.

Common Use Cases

  • Framework config loading - a CLI or framework like Nuxt or Nitro loads its nuxt.config.ts/nitro.config.ts plus any RC overrides with one loadConfig call.
  • Themeable config layers - a tool lets users extends a shared base config (local or from a GitHub template) for design systems or monorepo presets.
  • Environment-aware app config - an app defines $production/$development overrides in one config file instead of maintaining separate files per environment.
  • Dev-server hot reload - a build tool uses watchConfig to pick up config edits without a full process restart.

Under The Hood

Architecture c12 exposes a single entry (src/index.ts) that barrels four independently-testable modules: loader.ts (the core loadConfig/resolveConfig pipeline), watch.ts (a thin chokidar wrapper around loadConfig for HMR), dotenv.ts (env-file parsing/interpolation), and update.ts (magicast-based AST config mutation) — types.ts holds all shared type definitions with no runtime code, keeping the type surface separable from behavior. The core flow in loader.ts is a straight-line pipeline: normalize options, build a rawConfigs record keyed by ConfigSource (overrides/main/rc/packageJson/defaultConfig), resolve each source (awaiting function-valued sources against a ResolvableConfigContext), merge them via defu in a fixed precedence order, then recursively call extendConfig to walk the extends key and stitch remote/local layers into config._layers before a final merge pass folds every layer back into the top-level config. resolveConfig itself branches on source shape (a custom options.resolve hook, giget URI prefixes gh:/gitlab:/bitbucket:/https:, an npm-package-name regex, then a plain filesystem path) before dispatching to either an ASYNC_LOADERS format parser (YAML/JSONC/JSON5/TOML via confbox) or a native import() with a jiti fallback for legacy TS/CJS syntax — dependency injection appears via optional import/resolve/resolveModule/merger callbacks that let downstream frameworks override any stage without forking the loader. The ConfigSource union type and the ordered rawConfigs iteration are load-bearing for the documented merge-priority guarantee; changing that iteration order would be a breaking change for every consumer relying on override semantics.

Tech Stack c12 is a pure-ESM TypeScript library (type: module, an exports map with .mjs outputs only, no CJS build) targeting Node with esnext/nodenext module resolution and strict TypeScript settings (strict, noUncheckedIndexedAccess, verbatimModuleSyntax). Its runtime dependency graph is deliberately small — confbox for YAML/JSONC/JSON5/TOML parsing, defu for deep-merge, exsolve for module path resolution, pathe for cross-platform paths, pkg-types for reading package.json/workspace roots, and rc9 for RC-file read/write — with heavier capabilities (chokidar for watching, giget for remote git extends, jiti for legacy TS/CJS loading, dotenv, magicast for AST-based config updates) declared as optional peer dependencies and dynamically imported only when a caller actually exercises that path, which is how the project reports install size dropping substantially between major versions. Build tooling is obuild for bundling the dist output, oxlint/oxfmt (Rust-based) for linting/formatting instead of ESLint/Prettier, vitest with v8 coverage for tests, and a CI matrix (GitHub Actions) running lint/build/type-check on Ubuntu plus a Windows-only vitest pass to catch path-separator regressions.

Code Quality Tests live under test/ and are fixture-driven rather than mock-heavy: loader.test.ts exercises loadConfig/watchConfig against real files under test/fixture/ (JS/TS/JSON/JSONC/JSON5/YAML/TOML variants, a node_modules-resolved npm-style layer, nested .config directories, array-exporting configs), global-rc.test.ts and loader-giget.test.ts isolate the RC and remote-extends paths, and dotenv.test.ts/update.test.ts cover the other two modules — coverage is measured via vitest’s v8 reporter and enforced in CI on both Ubuntu and Windows, the latter specifically to catch path-normalization regressions given the codebase’s explicit path-normalization helpers. Error handling favors explicit, actionable messages over silent failure: resolveConfig throws with an install hint when giget or jiti aren’t present as peer deps, though extendConfig currently logs a warning rather than throwing on an unresolvable extends source, a behavior the code itself flags as pending a breaking-change fix. Typing is strict throughout and generics thread the user config shape and meta shape through nearly every exported function so consumers get typed loadConfig results; naming is consistent and low-abbreviation, and linting/formatting is enforced via an automated bot that pushes lint fixes back to pull requests.

API Design The public surface is intentionally small — loadConfig, watchConfig, loadDotenv, setupDotenv, updateConfig, createDefineConfig, and a SUPPORTED_EXTENSIONS constant — and every loader function accepts a single options object rather than positional arguments, so adding a new option is additive and non-breaking. Getting started requires zero config: calling loadConfig with an empty options object immediately resolves a merged config object from whatever sources exist in the working directory, and createDefineConfig lets a downstream framework generate its own typed defineConfig helper in one line, with a reserved $meta namespace that’s automatically stripped from the resolved config and exposed separately as layer metadata — a small but effective convention for separating options for the loader from options about a layer. Documentation is thorough for a library this size: the README documents every option individually with runnable examples (dotenv interpolation, secret-file expansion, environment-specific overrides, remote extends with auth), and a separate contributor-facing document covers internal architecture, the dependency table, and code conventions.

Join founders buildingwith open source

Opinionated takes, migration guides, cost-saving tips, and insights from the open source ecosystem.

Subscribe on Substack
Join 750+ subscribers

Search