@ngandu-dev/config loads explicit environment and configuration sources, resolves environment references, validates everything with Zod, and returns deeply readonly values with full TypeScript inference.
- One async startup API for local files, remote providers, and async validation
- Schema-derived environment autocomplete and native value types
- Explicit JSON, YAML, inline, and provider sources
- Deterministic precedence with concurrent provider loading
- No mutation of
process.envor caller-owned values - Aggregated, structured, and secret-aware diagnostics
- Immutable configuration with source provenance
- No shell execution, automatic file discovery, or ambiguous source objects
- Node.js 20.17 or newer
- An ESM application (
"type": "module"or an.mjsentry point) - Zod 4
npm install @ngandu-dev/config zodJSON and YAML support are included. Zod remains a peer dependency so applications control their validation version.
Create an environment file:
# .env
PORT=3000
DATABASE_URL=postgres://app:secret@localhost:5432/app
LOGGER_PRETTY=falseCreate a configuration source:
# config/base.yaml
http:
host: "0.0.0.0"
port: "%env(PORT)%"
database:
url: "%env(DATABASE_URL)%"
logger:
pretty: "%env(LOGGER_PRETTY)%"Define and export the application configuration:
// src/config.ts
import { defineConfig, yamlFile } from "@ngandu-dev/config";
import { z } from "zod";
const EnvironmentSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("production"),
PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
DATABASE_URL: z.url(),
LOGGER_PRETTY: z.stringbool().default(false),
});
const ConfigurationSchema = z.object({
http: z.object({
host: z.string(),
port: z.number(),
}),
database: z.object({
url: z.url(),
}),
logger: z.object({
pretty: z.boolean(),
}),
});
export const { config, env, metadata } = await defineConfig({
environment: {
schema: EnvironmentSchema,
files: [
{ path: ".env", optional: true },
{ path: ".env.local", optional: true },
],
redact: ["DATABASE_URL"],
},
schema: ConfigurationSchema,
sources: [yamlFile("config/base.yaml", { name: "base" })],
});Use the validated values during service startup:
import { config, env } from "./config";
console.log(`Starting in ${env.NODE_ENV}`);
server.listen({
host: config.http.host,
port: config.http.port,
});The inferred values are:
env.PORT; // number
env.LOGGER_PRETTY; // boolean
config.database.url; // string
// Compile-time error: unknown environment key
env.NOT_DECLARED;
// Compile-time and runtime error: configuration is deeply readonly
config.http.port = 4000;defineConfig is intentionally async-only. A backend service should load configuration once during bootstrap and fail before accepting traffic when configuration is invalid.
const result = await defineConfig({
schema,
environment,
defaults,
sources,
cwd,
});| Option | Required | Description |
|---|---|---|
schema |
Yes | Zod schema for the final merged configuration |
environment |
No | Environment schema, dotenv files, process input, overrides, and redaction policy |
defaults |
No | Initial values merged before every declared source |
sources |
No | Ordered JSON, YAML, inline, or provider sources |
cwd |
No | Base directory for relative paths; defaults to process.cwd() |
The result contains:
| Property | Description |
|---|---|
config |
Deeply readonly output of the configuration schema |
env |
Deeply readonly output of the environment schema, or an empty object when none is configured |
metadata |
Value-free source, environment, redaction, and provenance diagnostics |
Both full Zod and Zod Mini schemas are supported.
The environment schema is the single source of truth for variable names, defaults, coercion, validation, and TypeScript autocomplete.
const EnvironmentSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("production"),
PORT: z.coerce.number().int().positive(),
DEBUG: z.stringbool().default(false),
OPTIONAL_TOKEN: z.string().optional(),
});
const result = await defineConfig({
environment: {
schema: EnvironmentSchema,
files: [".env", { path: ".env.local", optional: true }],
processEnv: process.env,
overrides: { NODE_ENV: "test" },
redact: ["OPTIONAL_TOKEN"],
},
schema: z.object({}),
});Raw environment values are applied from lowest to highest priority:
- Files in their declared order
processEnv, which defaults to a snapshot ofprocess.env- Explicit
overrides
The input is snapshotted before asynchronous work begins. Neither process.env nor a supplied processEnv or overrides object is modified.
Use isolated inputs in tests or controlled deployments:
environment: {
schema: EnvironmentSchema,
files: [".env.test"],
processEnv: false,
overrides: {
NODE_ENV: "test",
PORT: "4000",
},
}Environment files are required unless their entry uses optional: true. Files are never discovered automatically.
Environment files support:
- Blank lines, comments, and
export - Unquoted, single-quoted, double-quoted, and multiline quoted values
$NAMEand${NAME}expansion${NAME-default}when a value is undefined${NAME:-default}when a value is undefined or empty- Expansion from earlier file entries and the supplied process input
Shell substitutions such as $(command) remain literal text and are never executed.
For isolated low-level parsing, use parseDotenv:
import { parseDotenv } from "@ngandu-dev/config";
const values = parseDotenv("URL=https://$HOST:$PORT", {
context: { HOST: "localhost", PORT: "3000" },
source: "generated.env",
});Configuration sources reference validated environment output with %env(NAME)%.
A whole-value reference preserves the Zod output type:
http:
port: "%env(PORT)%" # number
logger:
pretty: "%env(LOGGER_PRETTY)%" # booleanAn embedded reference is converted to a string:
healthUrl: "https://%env(HOST)%:%env(PORT)%/health"Missing references are collected and reported together with their configuration paths. Type coercion belongs in the environment schema rather than in the placeholder.
Every source is explicit and discriminated. Inline data can therefore safely contain ordinary fields named path or type.
import { jsonFile, yamlFile } from "@ngandu-dev/config";
const sources = [
jsonFile("config/base.json", { name: "base" }),
yamlFile("config/production.yaml", {
name: "deployment",
optional: true,
}),
];Relative paths resolve from cwd. Missing optional sources are recorded in metadata with loaded: false; other read and parse failures stop startup.
import { inline } from "@ngandu-dev/config";
inline(
{
application: {
path: "/srv/application",
region: "af-south-1",
},
},
{ name: "runtime" },
);Providers integrate secret managers, service discovery, or computed application values:
import { provider } from "@ngandu-dev/config";
const sources = [
provider("vault", async () => ({
database: {
password: await vault.read("database/password"),
},
})),
provider("runtime", () => ({
application: {
region: "af-south-1",
},
})),
];A provider may return an immediate object or a promise-like value. Providers are fetched concurrently and their results are merged in declaration order. Provider failure messages and causes are discarded so upstream exceptions cannot expose secrets.
- Defaults are applied first
- Sources are merged in declaration order
- Later scalar values replace earlier values
- Plain objects merge recursively
- Arrays replace earlier arrays rather than concatenating
undefinedvalues from a later source replace earlier values
Sources and schema outputs may contain configuration-safe primitives, arrays, and plain objects. Cycles, functions, symbols, and mutable class instances are rejected before results are frozen.
config, env, and metadata are cloned and deeply frozen. The loader never freezes caller-owned defaults, inline values, provider results, schemas, or environment inputs.
const source = { http: { port: 3000 } };
const result = await defineConfig({
schema: z.object({ http: z.object({ port: z.number() }) }),
sources: [inline(source)],
});
Object.isFrozen(source); // false
Object.isFrozen(result.config); // true
Object.isFrozen(result.config.http); // trueMetadata contains operational context without configuration or environment values:
metadata.sources;
// [{ type: "yaml", name: "base", path: "...", optional: false, loaded: true }]
metadata.environment.keys;
// ["DATABASE_URL", "NODE_ENV", "PORT"]
metadata.environment.redactedKeys;
// ["DATABASE_URL"]
metadata.provenance["http.port"];
// "base"Provenance records the winning input source for each leaf path before schema transforms. Arrays and empty objects are treated as atomic leaf values because they are replaced as complete values during merging.
All loading and validation failures use ConfigurationError:
import { ConfigurationError } from "@ngandu-dev/config";
try {
await defineConfig(options);
} catch (error) {
if (error instanceof ConfigurationError) {
logger.fatal({ code: error.code, issues: error.issues }, "Invalid configuration");
process.exitCode = 1;
}
}Environment validation, configuration validation, and missing environment references aggregate all issues found during that stage.
| Code | Meaning |
|---|---|
CONFIG_INVALID |
Final configuration failed validation or produced an unsupported value |
ENV_FILE_INVALID |
An environment file could not be read or parsed |
ENV_FILE_MISSING |
A required environment file does not exist |
ENV_INVALID |
Environment schema validation failed or produced an unsupported value |
ENV_REFERENCE_MISSING |
One or more %env(NAME)% references could not be resolved |
INVALID_OPTIONS |
Defaults or another option violated the API contract |
SOURCE_INVALID |
A source could not be read, parsed, or represented safely |
SOURCE_MISSING |
A required JSON or YAML source does not exist |
SOURCE_UNAVAILABLE |
A provider failed to load |
Keys listed in environment.redact receive generic validation and reference messages. Raw environment and configuration values are never stored in metadata or formatted package errors.
Prefer isolated environment input so tests never depend on or modify the runner process:
const result = await defineConfig({
environment: {
schema: EnvironmentSchema,
processEnv: false,
overrides: {
NODE_ENV: "test",
PORT: "4000",
DATABASE_URL: "postgres://test:test@localhost:5432/test",
},
},
schema: ConfigurationSchema,
sources: [inline(testConfiguration)],
});Because providers are ordinary functions, tests can replace remote integrations without mocking package internals.
Version 2 is ESM-only. Declare ESM in the consuming application's package.json:
{
"type": "module"
}Then load configuration from an ESM bootstrap. Top-level await is supported, or it can remain inside the application's async startup function:
import { defineConfig } from "@ngandu-dev/config";
const { config } = await defineConfig(options);
await startServer(config);Version 3 is published under a new organization and is intentionally incompatible with the old
package coordinate. Install and import @ngandu-dev/config; no compatibility package or legacy
scope is provided. The configuration API introduced in version 2 remains the basis of version 3.
Before:
const { config, env } = defineConfig({
env: {
knownKeys: ["PORT"],
},
schema,
sources: ["config.yaml"],
});
env("PORT");After:
const { config, env } = await defineConfig({
environment: {
schema: z.object({
PORT: z.coerce.number().int().positive(),
}),
files: [{ path: ".env", optional: true }],
},
schema,
sources: [yamlFile("config.yaml")],
});
env.PORT;Migration checklist:
- Add
awaittodefineConfigduring application bootstrap - Replace
env,env: true, andknownKeyswithenvironment: { schema, ... } - Replace
env("NAME")with property access such asenv.NAME - Wrap sources with
jsonFile,yamlFile,inline, orprovider - Replace typed placeholders such as
%env(number:PORT)%with%env(PORT)% - Move coercion and defaults into the environment schema
- Declare environment files explicitly
- Remove uses of
env.has,env.optional,env.keys,createEnvAccessor, the sharedenvexport, andDotenv - Remove INI sources and dependencies
- Remove command expansion directives; commands are never executed in v2
- Treat returned configuration, environment, and metadata as deeply readonly
Install dependencies with bun install, then run bun run quality before opening a pull request.
See CONTRIBUTING.md for the complete contribution workflow.
Run bun run test for the test suite or bun run test:coverage for a coverage report.
Contributions are welcome. Please read CONTRIBUTING.md and follow our Code of Conduct.
Please report vulnerabilities privately as described in SECURITY.md.
Released under the MIT License.