# @meltstudio/config-loader > A type-safe configuration loader for Node.js — YAML, JSON, TOML, .env, environment variables, and CLI arguments. This file contains all documentation content in a single document following the llmstxt.org standard. ## Getting Started # @meltstudio/config-loader A type-safe configuration loader for Node.js. Define your schema once, load from YAML, JSON, or TOML files, `.env` files, environment variables, and CLI arguments — and get a fully typed result with zero manual type annotations. > **Upgrading from v1?** v1.x is deprecated and no longer maintained. Install the latest version with `npm install @meltstudio/config-loader@latest`. ## Why config-loader? Most config libraries give you `Record` and leave you to cast or validate manually. config-loader infers TypeScript types directly from your schema definition: ```typescript import c from "@meltstudio/config-loader"; const config = c .schema({ port: c.number({ required: true, env: "PORT" }), database: c.object({ item: { host: c.string({ required: true }), credentials: c.object({ item: { username: c.string(), password: c.string({ env: "DB_PASSWORD", sensitive: true }), }, }), }, }), features: c.array({ required: true, item: c.object({ item: { name: c.string(), enabled: c.bool(), }, }), }), }) .load({ env: true, args: true, files: "./config.yaml", }); // config is fully typed: // { // port: number; // database: { host: string; credentials: { username: string; password: string } }; // features: { name: string; enabled: boolean }[]; // } ``` No separate interface to maintain. No `as` casts. The types flow from the schema. ## Features - **Full type inference** — schema definition produces typed output automatically - **Multiple sources** — YAML, JSON, TOML files, `.env` files, environment variables, CLI arguments - **Priority resolution** — CLI > process.env > `.env` files > Config files > Defaults - **`.env` file support** — load environment variables from `.env` files with automatic line tracking - **Nested objects and arrays** — deeply nested configs with full type safety - **Structured errors** — typed `ConfigLoadError` with per-field error details and warnings - **Enum constraints** — restrict values to a fixed set with `oneOf`, with full type narrowing - **Sensitive fields** — mark fields with `sensitive: true` to auto-mask in `printConfig()` and `maskSecrets()` - **Schema validation** — optional per-field validation via [Standard Schema](https://github.com/standard-schema/standard-schema) (Zod, Valibot, ArkType, or custom) - **Strict mode** — promote warnings to errors for production safety - **Default values** — static or computed (via functions) - **Multiple files / directory loading** — load from a list of files or an entire directory - **File watching** — [`watch()`](./loading-and-sources#watch-mode) reloads config on file changes with debouncing, change detection, and error recovery ## Requirements - Node.js >= 20 ## Installation ```bash npm install @meltstudio/config-loader ``` ```bash pnpm add @meltstudio/config-loader ``` ```bash yarn add @meltstudio/config-loader ``` ## Quick Start import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Create a config file in your preferred format: ```yaml title="config.yaml" version: 1.0.0 website: title: My Website description: A simple and elegant website isProduction: false database: host: localhost port: 5432 credentials: username: admin password: secret socialMedia: [https://twitter.com/example, https://instagram.com/example] features: - name: Store enabled: true - name: Admin enabled: false ``` ```toml title="config.toml" version = "1.0.0" socialMedia = ["https://twitter.com/example", "https://instagram.com/example"] [website] title = "My Website" description = "A simple and elegant website" isProduction = false [database] host = "localhost" port = 5432 [database.credentials] username = "admin" password = "secret" [[features]] name = "Store" enabled = true [[features]] name = "Admin" enabled = false ``` ```json title="config.json" { "version": "1.0.0", "website": { "title": "My Website", "description": "A simple and elegant website", "isProduction": false }, "database": { "host": "localhost", "port": 5432, "credentials": { "username": "admin", "password": "secret" } }, "socialMedia": [ "https://twitter.com/example", "https://instagram.com/example" ], "features": [ { "name": "Store", "enabled": true }, { "name": "Admin", "enabled": false } ] } ``` **index.ts:** ```typescript import path from "path"; import c from "@meltstudio/config-loader"; const config = c .schema({ version: c.string({ required: true, cli: true }), website: c.object({ item: { title: c.string({ required: true }), url: c.string({ required: false, defaultValue: "www.mywebsite.dev", }), description: c.string({ required: true }), isProduction: c.bool({ required: true }), }, }), database: c.object({ item: { host: c.string({ required: true }), port: c.number({ required: true }), credentials: c.object({ item: { username: c.string(), password: c.string(), }, }), }, }), socialMedia: c.array({ required: true, item: c.string({ required: true }), }), features: c.array({ required: true, item: c.object({ item: { name: c.string(), enabled: c.bool(), }, }), }), }) .load({ env: false, args: true, files: path.join(__dirname, "./config.yaml"), }); console.log(JSON.stringify(config, null, 2)); ``` Output: ```json { "version": "1.0.0", "website": { "title": "My Website", "url": "www.mywebsite.dev", "description": "A simple and elegant website", "isProduction": false }, "database": { "host": "localhost", "port": 5432, "credentials": { "username": "admin", "password": "secret" } }, "socialMedia": [ "https://twitter.com/example", "https://instagram.com/example" ], "features": [ { "name": "Store", "enabled": true }, { "name": "Admin", "enabled": false } ] } ``` --- ## Loading & Sources ## Loading Config Call `.load()` on a schema to resolve all values and return a plain typed object: ```typescript const config = c .schema({ port: c.number({ required: true, env: "PORT", defaultValue: 3000 }), host: c.string({ required: true, env: "HOST", defaultValue: "localhost" }), }) .load({ env: true, args: false, files: "./config.yaml" }); ``` ### Source Options ```typescript .load({ env: true, // Read from process.env args: true, // Read from CLI arguments (--database.port 3000) files: "./config.yaml", // Single YAML file files: "./config.json", // Single JSON file files: "./config.toml", // Single TOML file files: ["./base.yaml", "./overrides.json"], // Mix formats (first takes priority) dir: "./config.d/", // All files in a directory (sorted) envFile: "./.env", // Single .env file envFile: ["./.env", "./.env.local"], // Multiple .env files (later overrides earlier) defaults: { port: 3000 }, // Programmatic defaults }) ``` YAML (`.yaml`, `.yml`), JSON (`.json`), and TOML (`.toml`) files are supported. The format is detected automatically from the file extension. ### Priority Order **CLI arguments > `process.env` > `.env` files > Config files > Defaults** ## Config Files Load from one or more YAML/JSON/TOML files, or from an entire directory: ```typescript // Single file .load({ env: false, args: false, files: "./config.yaml" }) // Multiple files (first takes priority) .load({ env: false, args: false, files: ["./base.yaml", "./overrides.yaml"] }) // All files in a directory (sorted by filename) .load({ env: false, args: false, dir: "./config.d/" }) ``` ## Environment Variables Set `env: "VAR_NAME"` on an option and `env: true` in the load options: ```typescript c.schema({ database: c.object({ item: { password: c.string({ env: "DB_PASSWORD" }), }, }), }).load({ env: true, args: false, files: "./config.yaml" }); ``` :::tip If you set `env: "PORT"` on a field but load with `env: false`, config-loader emits a warning: _"Options [port] have env mappings but env loading is disabled"_. Make sure to set `env: true` when using env mappings. ::: ## `.env` File Support Load environment variables from `.env` files using the `envFile` option. Options with an `env` mapping automatically pick up values from `.env` files — no new syntax needed on individual fields. **.env:** ```bash DB_HOST=localhost DB_PORT=5432 DB_PASSWORD="s3cret" APP_NAME='My App' # This is a comment ``` **Usage:** ```typescript const config = c .schema({ host: c.string({ env: "DB_HOST" }), port: c.number({ env: "DB_PORT" }), password: c.string({ env: "DB_PASSWORD" }), }) .load({ env: true, args: false, envFile: "./.env", }); ``` `process.env` always takes precedence over `.env` file values. This means you can use `.env` files for development defaults while overriding them in production via real environment variables. **Multiple `.env` files:** ```typescript .load({ env: true, args: false, envFile: ["./.env", "./.env.local"], // .env.local overrides .env }) ``` When using multiple files, later files override earlier ones for the same key. The `.env` parser supports: - `KEY=VALUE` pairs (whitespace trimmed) - Comments (lines starting with `#`) - Quoted values (double `"..."` or single `'...'` quotes stripped) - Empty values (`KEY=`) :::tip `.env` files are **not** loaded by default. You must explicitly pass `envFile` in the load options — setting `env: true` alone only reads `process.env`. ::: ## CLI Arguments Set `cli: true` on an option to allow overriding via command line: ```typescript c.schema({ version: c.string({ required: true, cli: true }), }); ``` ```bash node app.js --version 2.0.0 ``` ## Extended Loading (Source Metadata) Use `loadExtended()` instead of `load()` to get each value wrapped in a `ConfigNode` that includes source metadata — where the value came from, which file, environment variable, or CLI argument provided it. ```typescript import c from "@meltstudio/config-loader"; const { data, warnings } = c .schema({ port: c.number({ required: true, env: "PORT" }), host: c.string({ defaultValue: "localhost" }), }) .loadExtended({ env: true, args: false, files: "./config.yaml", }); // `warnings` is a string[] of non-fatal issues (e.g. type coercions, unused env mappings) if (warnings.length > 0) { warnings.forEach((w) => console.warn(w)); } // Each leaf in `data` is a ConfigNode with: // { // value: 3000, // path: "port", // sourceType: "env" | "envFile" | "file" | "args" | "default", // file: "./config.yaml" | "./.env" | null, // variableName: "PORT" | null, // argName: null, // line: 5 | null, // source line (1-based) for YAML, JSON, TOML, and .env files // column: 3 | null // source column (1-based) for YAML, JSON, TOML, and .env files // } console.log(data.port.value); // 3000 console.log(data.port.sourceType); // "env" console.log(data.port.variableName); // "PORT" ``` This is useful for debugging configuration resolution, building admin UIs that show where each setting originated, or auditing which sources are active. ## `printConfig` Use `printConfig()` to format the result of `loadExtended()` as a readable table. Fields marked with [`sensitive: true`](./schema-api#sensitive-fields) are automatically masked. ```typescript import c, { printConfig } from "@meltstudio/config-loader"; const result = c .schema({ host: c.string({ defaultValue: "localhost" }), port: c.number({ env: "PORT" }), apiKey: c.string({ env: "API_KEY", sensitive: true }), }) .loadExtended({ env: true, args: true, files: "./config.yaml" }); printConfig(result); ``` Output: ``` ┌────────┬───────────┬─────────┬────────────────┐ │ Path │ Value │ Source │ Detail │ ├────────┼───────────┼─────────┼────────────────┤ │ host │ localhost │ default │ │ │ port │ 8080 │ env │ PORT │ │ apiKey │ *** │ env │ API_KEY │ └────────┴───────────┴─────────┴────────────────┘ ``` Options: - `printConfig(result, { silent: true })` — returns the string without printing to console - `printConfig(result, { maxValueLength: 30 })` — truncate long values (default: 50) ## `maskSecrets` Use `maskSecrets()` to create a safe-to-log copy of your config with [`sensitive`](./schema-api#sensitive-fields) values replaced by `"***"`: ```typescript import c, { maskSecrets } from "@meltstudio/config-loader"; const schema = { host: c.string({ defaultValue: "localhost" }), apiKey: c.string({ env: "API_KEY", sensitive: true }), }; // With a plain config from load() const config = c.schema(schema).load({ env: true, args: false }); console.log(maskSecrets(config, schema)); // { host: "localhost", apiKey: "***" } // With an extended result from loadExtended() const result = c.schema(schema).loadExtended({ env: true, args: false }); const masked = maskSecrets(result); // masked.data contains ConfigNodes with "***" for sensitive values ``` The original config object is never mutated — `maskSecrets()` always returns a new copy. ## Watch Mode Use `watch()` to automatically reload config when files change. File watchers are `.unref()`'d so they don't prevent the process from exiting. ```typescript import c from "@meltstudio/config-loader"; const schema = { port: c.number({ env: "PORT", defaultValue: 3000 }), host: c.string({ env: "HOST", defaultValue: "localhost" }), apiKey: c.string({ env: "API_KEY", sensitive: true }), }; const watcher = c.schema(schema).watch( { env: true, args: false, files: "./config.yaml" }, { onChange: (newConfig, oldConfig, changes) => { console.log("Config updated:"); for (const change of changes) { console.log( ` ${change.path}: ${change.oldValue} → ${change.newValue}`, ); } }, onError: (err) => { console.error("Config reload failed:", err.message); }, debounce: 100, // milliseconds (default: 100) }, ); // Access the current config at any time console.log(watcher.config.port); // Stop watching when done watcher.close(); ``` ### How it works - The initial load happens synchronously — if it fails, `watch()` throws immediately (same as `load()`) - On file changes, config is reloaded after the debounce interval - If the reload succeeds and values changed, `onChange` is called with the new config, old config, and a list of changes - If the reload fails (parse error, validation error), `onError` is called and the previous config is retained - Sensitive fields are masked with `"***"` in the change list - `close()` stops all watchers and is idempotent ### `ConfigChange` Each change in the `changes` array has: ```typescript interface ConfigChange { path: string; // Dot-separated path (e.g. "db.host") oldValue: unknown; // Previous value (undefined if added) newValue: unknown; // New value (undefined if removed) type: "added" | "removed" | "changed"; } ``` ### `diffConfig` You can also use `diffConfig()` directly to compare two config objects: ```typescript import { diffConfig } from "@meltstudio/config-loader"; const changes = diffConfig(oldConfig, newConfig, schema); // schema is optional — when provided, sensitive fields are masked ``` ## Error Handling When validation fails, config-loader throws a `ConfigLoadError` with structured error details: ```typescript import c, { ConfigLoadError } from "@meltstudio/config-loader"; try { const config = c.schema({ port: c.number({ required: true }) }).load({ env: false, args: false, files: "./config.yaml", }); } catch (err) { if (err instanceof ConfigLoadError) { for (const entry of err.errors) { console.error(`[${entry.kind}] ${entry.message}`); // e.g. [required] Required option 'port' not provided. } // err.warnings contains non-fatal issues } } ``` Warnings (non-fatal issues like type coercions) are never printed to the console. Use `loadExtended()` to access them, or they are included in `ConfigLoadError.warnings` when errors occur. ## Strict Mode Enable `strict: true` to promote all warnings to errors, causing `ConfigLoadError` to be thrown for any ambiguous or lossy configuration: ```typescript .load({ env: true, args: false, files: "./config.yaml", strict: true, }) ``` This is useful in production environments where you want to catch type coercions, null values, and other ambiguous config early rather than silently accepting them. --- ## Schema API Define your configuration shape using factory functions. Each field accepts options like `required`, `env`, `cli`, `defaultValue`, `sensitive`, `oneOf`, and `validate`. ## Primitives ```typescript c.string({ required: true, env: "MY_VAR", cli: true, defaultValue: "fallback", }); c.number({ required: true, env: "PORT" }); c.bool({ env: "DEBUG", defaultValue: false }); ``` All factory functions can be called with no arguments for optional fields with no special behavior: ```typescript c.string(); // optional string, no env/cli mapping c.number(); // optional number c.bool(); // optional boolean ``` ## Objects Use `c.object()` to declare nested object schemas. Fields must be inside the `item` property: ```typescript c.object({ item: { host: c.string(), port: c.number(), }, }); ``` :::tip A common mistake is passing fields directly to `c.object()` instead of wrapping them in `item`: ```typescript // WRONG — fields are passed directly c.object({ host: c.string(), port: c.number() }); // CORRECT — fields must be inside `item` c.object({ item: { host: c.string(), port: c.number() } }); ``` ::: Objects can be nested arbitrarily deep: ```typescript c.schema({ database: c.object({ item: { host: c.string(), port: c.number(), credentials: c.object({ item: { username: c.string(), password: c.string({ env: "DB_PASSWORD" }), }, }), }, }), }); ``` `c.object()` accepts a `required` option (defaults to `false`). When the entire subtree is absent from all sources, child `required` options will trigger errors through normal validation. ## Arrays ```typescript c.array({ required: true, item: c.string() }); // string[] c.array({ required: true, item: c.number() }); // number[] c.array({ item: c.object({ item: { name: c.string(), age: c.number() }, }), }); // { name: string; age: number }[] ``` ## Enum Constraints (`oneOf`) Use `oneOf` to restrict a field to a fixed set of allowed values. The check runs after type coercion and before any `validate` schema: ```typescript const config = c .schema({ env: c.string({ env: "NODE_ENV", defaultValue: "development", oneOf: ["development", "staging", "production"], }), logLevel: c.number({ env: "LOG_LEVEL", defaultValue: 1, oneOf: [0, 1, 2, 3], }), }) .load({ env: true, args: false }); ``` If a value is not in the allowed set, a `ConfigLoadError` is thrown with `kind: "validation"`. ### Type Narrowing When `oneOf` is provided, the inferred type is automatically narrowed to the union of the allowed values: ```typescript const config = c .schema({ env: c.string({ oneOf: ["dev", "staging", "prod"] }), }) .load({ env: false, args: false }); // config.env is typed as "dev" | "staging" | "prod", not string ``` When used with `cli: true`, the `--help` output automatically lists the allowed values. ## Sensitive Fields Mark fields as `sensitive: true` to prevent their values from being exposed in logs or debug output: ```typescript const schema = { host: c.string({ defaultValue: "localhost" }), apiKey: c.string({ env: "API_KEY", sensitive: true }), db: c.object({ item: { host: c.string({ defaultValue: "db.local" }), password: c.string({ env: "DB_PASS", sensitive: true }), }, }), }; const config = c.schema(schema).load({ env: true, args: false }); ``` Sensitive values load normally — `config.apiKey` returns the real value. The flag only affects the masking utilities [`printConfig()`](./loading-and-sources#printconfig) and [`maskSecrets()`](./loading-and-sources#masksecrets). ## Validation Add per-field validation using the `validate` option. config-loader accepts any [Standard Schema v1](https://github.com/standard-schema/standard-schema) implementation — including **Zod**, **Valibot**, and **ArkType** — or a custom validator. Validation runs **after** type coercion, so validators see the final typed value (e.g., the number `3000`, not the string `"3000"` from an env var). ### With Zod ```typescript import c from "@meltstudio/config-loader"; import { z } from "zod"; const config = c .schema({ port: c.number({ required: true, env: "PORT", validate: z.number().min(1).max(65535), }), host: c.string({ required: true, validate: z.string().url(), }), env: c.string({ defaultValue: "development", validate: z.enum(["development", "staging", "production"]), }), }) .load({ env: true, args: false, files: "./config.yaml" }); ``` ### With a custom validator Any object with a `~standard.validate()` method works: ```typescript const portValidator = { "~standard": { version: 1, vendor: "my-app", validate(value: unknown) { if (typeof value === "number" && value >= 1 && value <= 65535) { return { value }; } return { issues: [{ message: "must be a valid port (1-65535)" }] }; }, }, }; c.number({ required: true, env: "PORT", validate: portValidator }); ``` ### Validation pipeline The full validation pipeline for each field is: **resolve value → type coerce → `oneOf` check → `validate` schema**. If `oneOf` fails, `validate` is skipped. If type coercion fails, both `oneOf` and `validate` are skipped. --- ## TypeScript Utilities config-loader exports several types and utilities for advanced use cases: ```typescript import c, { type SchemaValue, // Infer the resolved config type from a schema type SettingsSources, // Type for the sources object passed to load() type ExtendedResult, // Return type of loadExtended() type NodeTree, // Tree of ConfigNode objects (ExtendedResult.data) ConfigNode, // Class representing a resolved value with source metadata ConfigNodeArray, // Class representing an array of ConfigNode values type RecursivePartial, // Deep partial utility used by the defaults option type StandardSchemaV1, // Standard Schema v1 interface for validators type ConfigChange, // A single change detected between config reloads type ConfigWatcher, // Handle returned by watch() with config and close() type WatchOptions, // Options for the watch() method maskSecrets, // Create a safe-to-log copy with sensitive values masked printConfig, // Format loadExtended() result as a readable table diffConfig, // Compare two config objects and return a list of changes ConfigLoadError, // Structured error thrown on validation failure ConfigFileError, // Error thrown on file parse failure } from "@meltstudio/config-loader"; ``` ## `SchemaValue` The most commonly needed type utility. Infers the plain TypeScript type from a schema definition: ```typescript const mySchema = { port: c.number({ env: "PORT" }), db: c.object({ item: { host: c.string(), port: c.number() } }), }; type MyConfig = SchemaValue; // { port: number; db: { host: string; port: number } } ``` This is useful when you want to pass the config object to functions that need an explicit type annotation: ```typescript function startServer(config: MyConfig) { // config.port and config.db are fully typed } ``` ## `oneOf` Type Narrowing When using `oneOf`, `SchemaValue` automatically narrows the type to the union of allowed values: ```typescript const schema = { env: c.string({ oneOf: ["dev", "staging", "prod"] }), level: c.number({ oneOf: [0, 1, 2, 3] }), }; type Config = SchemaValue; // { env: "dev" | "staging" | "prod"; level: 0 | 1 | 2 | 3 } ``` ## License Built by [Melt Studio](https://meltstudio.co). Licensed under the [MIT License](https://github.com/MeltStudio/config-loader/blob/main/LICENSE).