npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ngandu-dev/config

v3.0.1

Published

Typed, immutable, schema-validated configuration for Node.js backend services

Readme

Config : Typesafe configuration loader

npm npm Quality Tests GitHub


@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.

Features

  • 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.env or caller-owned values
  • Aggregated, structured, and secret-aware diagnostics
  • Immutable configuration with source provenance
  • No shell execution, automatic file discovery, or ambiguous source objects

Requirements

  • Node.js 20.17 or newer
  • An ESM application ("type": "module" or an .mjs entry point)
  • Zod 4

Installation

npm install @ngandu-dev/config zod

JSON and YAML support are included. Zod remains a peer dependency so applications control their validation version.

Quick start

Create an environment file:

# .env
PORT=3000
DATABASE_URL=postgres://app:secret@localhost:5432/app
LOGGER_PRETTY=false

Create 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

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.

Environment configuration

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({}),
});

Precedence

Raw environment values are applied from lowest to highest priority:

  1. Files in their declared order
  2. processEnv, which defaults to a snapshot of process.env
  3. 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.

Dotenv syntax

Environment files support:

  • Blank lines, comments, and export
  • Unquoted, single-quoted, double-quoted, and multiline quoted values
  • $NAME and ${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",
});

Environment references

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)%" # boolean

An 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.

Configuration sources

Every source is explicit and discriminated. Inline data can therefore safely contain ordinary fields named path or type.

JSON and YAML

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.

Inline values

import { inline } from "@ngandu-dev/config";

inline(
  {
    application: {
      path: "/srv/application",
      region: "af-south-1",
    },
  },
  { name: "runtime" },
);

Providers

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.

Merge behavior

  • 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
  • undefined values 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.

Immutability

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); // true

Metadata and provenance

Metadata 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.

Errors

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.

Testing configuration

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.

ESM runtime

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);

Migrating to @ngandu-dev/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.

Migrating from 1.1.1

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 await to defineConfig during application bootstrap
  • Replace env, env: true, and knownKeys with environment: { schema, ... }
  • Replace env("NAME") with property access such as env.NAME
  • Wrap sources with jsonFile, yamlFile, inline, or provider
  • 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 shared env export, and Dotenv
  • Remove INI sources and dependencies
  • Remove command expansion directives; commands are never executed in v2
  • Treat returned configuration, environment, and metadata as deeply readonly

Development

Install dependencies with bun install, then run bun run quality before opening a pull request. See CONTRIBUTING.md for the complete contribution workflow.

Testing

Run bun run test for the test suite or bun run test:coverage for a coverage report.

Contributing

Contributions are welcome. Please read CONTRIBUTING.md and follow our Code of Conduct.

Security

Please report vulnerabilities privately as described in SECURITY.md.

License

Released under the MIT License.

Contributors