Menu
On this page

@setu-ts/config-plugin

Configuration management plugin for Setu-TS with strict .env parsing, variable expansion, and structurally compatible schema validation.

Installation

npm install @setu-ts/config-plugin
# Deno: deno add npm:@setu-ts/config-plugin

Quick Start

import { createApplication } from '@setu-ts/kernel';
import { ConfigPlugin } from '@setu-ts/config-plugin';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CAPABILITIES } from '@setu-ts/common';
import type { IConfig } from '@setu-ts/common';

const app = createApplication();

app.register(RuntimePlugin());
app.register(
  ConfigPlugin({
    envFilePath: ['.env.local', '.env'],
  }),
);

await app.start();

// Access configuration
const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const port = config.get('PORT', { default: '3000' });

Configuration a plugin needs at construction

Factory arguments are evaluated before app.start() registers ConfigPlugin and loads .env. Load a snapshot first when another plugin needs those values at construction, then register that same snapshot with instance; it is used verbatim without loading again:

import { createApplication } from '@setu-ts/kernel';
import { createRuntimeServices, RuntimePlugin } from '@setu-ts/runtime';
import { ConfigPlugin, loadConfig } from '@setu-ts/config-plugin';
import { AuthPlugin } from '@setu-ts/auth-plugin';

const runtime = createRuntimeServices();
const config = await loadConfig(runtime, { envFilePath: '.env', envFileOptional: true });
const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ConfigPlugin({ instance: config }),
    AuthPlugin({ jwt: { secret: config.getOrThrow<string>('JWT_SECRET') } }),
  ],
});
await app.start();

Options

OptionTypeDefaultDescription
envFilePathstring | readonly string[]undefinedPath(s) to .env files. No file loading when absent.
envFileOptionalbooleanfalseSkip an ABSENT configured path instead of throwing (unreadable still throws).
validationSchemaStructuralSchema<T>undefinedZod-compatible whole-snapshot schema for startup validation.
sectionsreadonly ConfigSection<unknown>[]undefinedTyped, declared-key sections to validate and cache at startup.
expandVariablesbooleantrueExpand ${NAME} references in values.
instanceIConfigundefinedAn already-loaded snapshot to register verbatim; nothing is read.
diagnosticsConfigDiagnosticsOptionsundefinedOpt-in value-free provenance; absent registers a disabled source.

Configuration Precedence

Values are merged in the following order (highest precedence first):

  1. Environment variables (runtime.env)
  2. Earlier file paths (.env.local overrides .env)
  3. Later file paths

undefined entries in runtime.env are filtered out.

Dotenv Parsing

Configured files are parsed strictly at startup. Blank lines and comments are ignored; export prefixes, quoted values, common double-quoted escapes, empty values, and whitespace-delimited inline comments are supported. Malformed entries, invalid keys, and unterminated quotes fail startup with a line number. Error messages never include the rejected value.

Variable Expansion

When expandVariables is true (default), ${NAME} references in values are resolved against the final merged configuration:

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_URL=postgresql://${DATABASE_HOST}:${DATABASE_PORT}/mydb
  • Supports recursive references.
  • Resolves references once, after every file and runtime.env have been merged.
  • Detects cycles spanning any combination of sources and throws with a clear error.
  • Fails for missing variable references.
  • Never uses eval or Function.

Validation with Zod

Pass a Zod schema to validationSchema for type coercion and validation at startup:

import { z } from 'zod';

const AppConfigSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  DEBUG: z.coerce.boolean().default(false),
});

app.register(
  ConfigPlugin({
    envFilePath: ['.env.local', '.env'],
    validationSchema: AppConfigSchema,
  }),
);
  • Coercions and defaults are preserved in the stored configuration.
  • The schema’s parsed output must be a non-null, non-array object.
  • Validation errors do not disclose secret values.

Typed Configuration Sections

Use a section when related settings should be returned with the type that its schema validated. Sections are validated and cached during startup; getConfigSection never parses or asserts a value at read time.

import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';

const database = defineConfigSection({
  prefix: 'DATABASE_',
  keys: ['URL', 'POOL_SIZE'],
  schema: z.object({
    URL: z.string().url(),
    POOL_SIZE: z.coerce.number().int().positive(),
  }),
});

app.register(ConfigPlugin({ sections: [database] }));

const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database);
// settings.POOL_SIZE is a number, validated before the application started.

keys contains prefix-stripped names: prefix: 'DATABASE_' plus keys: ['URL'] reads the flat DATABASE_URL key and supplies { URL } to the schema. The list is explicit because IConfig supports named reads but deliberately has no key-enumeration method; this also lets sections validate an arbitrary instance snapshot. A missing key is omitted, so the schema decides whether it is optional. A section schema is not interchangeable with validationSchema: the latter receives the whole flat snapshot, while the former receives only its declared, prefix-stripped keys.

Configuration Provenance

diagnostics is the explicit opt-in to value-free provenance for the approved keys only:

app.register(
  ConfigPlugin({
    envFilePath: ['.env.local', '.env'],
    diagnostics: {
      enabled: true, // the literal true: an acknowledgement, not a toggle
      keys: { PORT: 'port', DATABASE_URL: 'database' },
      files: { '.env.local': 'dotenv-local', '.env': 'dotenv' },
    },
  }),
);

During the ONE load already performed — never a second pass — the loader records where each approved key’s final value was observed to come from: the source category and approved source aliases, evidenced precedence displacement (overriddenSourceAliases, lowest first), ${NAME} expansion references (expanded + approved referenceAliases), and a schema effect derived only from input/output property presence. introduced reports that a key appeared only after schema parsing; it never names a mechanism, because a default and a transform are indistinguishable by presence. No value, hash, length, raw key name, or file path is ever retained, and unapproved keys are never observed at all.

loadConfig(runtime, { diagnostics }) builds the record with the snapshot; ConfigPlugin({ instance, diagnostics }) adopts that exact instance’s record — keeping real origins — or reports an opaque injected instance as unknown with no presence flag and no read of it. An adopted record is served as the loader built it, under the loadConfig call’s approvals; the plugin’s own keys and files then only enable the source and govern the opaque case, so pass the same policy to both calls. Provenance adds no read to any configuration object. The plugin always registers the source under CAPABILITIES.CONFIG_DIAGNOSTICS: absent diagnostics answers disabled. The Diagnostics Connector serves it at GET /v1/config — see docs/diagnostics-protocol.md.

Hot Reload

Deferred. The current runtime contract has no file-watching abstraction. Configuration is an immutable application-startup snapshot.

Edge Runtimes

On edge platforms (Cloudflare Workers, etc.) where runtime.fs is undefined, envFilePath must not be set. Attempting to do so throws a clear startup error:

ConfigPlugin: envFilePath requires a runtime with filesystem support.

API Reference

ConfigPlugin(options?)

Creates the configuration plugin. Consumes CAPABILITIES.RUNTIME, provides CAPABILITIES.CONFIG.

ConfigPluginOptions

import type { IConfig } from '@setu-ts/common';
import type { ConfigSection, StructuralSchema } from '@setu-ts/config-plugin';

interface ConfigPluginOptions {
  readonly envFilePath?: string | readonly string[];
  readonly envFileOptional?: boolean;
  readonly validationSchema?: StructuralSchema<unknown>;
  readonly sections?: readonly ConfigSection<unknown>[];
  readonly expandVariables?: boolean;
  readonly instance?: IConfig;
}

StructuralSchema<T>

Minimal schema interface compatible with Zod’s parse(unknown) API.

ConfigSection<T>

The typed section declaration returned by defineConfigSection. It carries prefix, prefix-stripped keys, and a StructuralSchema<T>. Pass it in ConfigPluginOptions.sections, then retrieve its validated output with getConfigSection(config, section).

IConfig (from @setu-ts/common)

interface IConfig {
  get<T>(key: string): T | undefined;
  get<T>(key: string, options: { readonly default: T }): T;
  getOrThrow<T>(key: string): T;
  has(key: string): boolean;
}

Without Schema

When validationSchema is not provided, all values remain as strings from the environment and .env files.

Dependencies

  • @setu-ts/common (workspace)
  • Consumer-supplied structural schema such as Zod (optional; not a package dependency)

Exports

ExportKind
ConfigPluginfunction
defineConfigSectionfunction
getConfigSectionfunction
loadConfigfunction
ConfigDiagnosticsOptionsinterface
ConfigPluginOptionsinterface
ConfigSectioninterface
StructuralSchemainterface

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

ConfigPlugin() (@setu-ts/config-plugin)

Provides strongly-typed configuration with environment validation and .env file loading. Configuration is an immutable application-startup snapshot — values are loaded once at startup and never mutated. Hot reload is deferred (the runtime contract has no file-watching abstraction).

Registration

import { ConfigPlugin } from '@setu-ts/config-plugin';
import { CAPABILITIES } from '@setu-ts/common';
import { z } from 'zod';

const AppConfigSchema = z.object({
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

app.register(
  ConfigPlugin({
    envFilePath: ['.env.local', '.env'],
    validationSchema: AppConfigSchema,
    expandVariables: true,
  }),
);

Usage

app.router.get('/config', (ctx) => {
  const config = ctx.services.get<IConfig>(CAPABILITIES.CONFIG);

  return ctx.response.json({
    port: config.get<number>('PORT'),
    env: config.get<string>('NODE_ENV'),
    hasRedis: config.has('REDIS_URL'),
    redisUrl: config.get<string>('REDIS_URL', { default: 'redis://localhost:6379' }),
  });
});

Config Interface

interface IConfig {
  get<T>(key: string): T | undefined;
  get<T>(key: string, options: { readonly default: T }): T;
  getOrThrow<T>(key: string): T;
  has(key: string): boolean;
}

ConfigPluginOptions

interface ConfigPluginOptions {
  readonly envFilePath?: string | readonly string[];
  readonly envFileOptional?: boolean;
  readonly validationSchema?: StructuralSchema<unknown>;
  readonly sections?: readonly ConfigSection<unknown>[];
  readonly expandVariables?: boolean;
  readonly instance?: IConfig;
  readonly diagnostics?: ConfigDiagnosticsOptions;
}

interface ConfigDiagnosticsOptions {
  readonly enabled: true;
  readonly keys: Readonly<Record<string, string>>;
  readonly files?: Readonly<Record<string, string>>;
}
  • envFilePath — Path or paths to .env files. Defaults to no file loading. When supplied, the runtime must provide fs (absent on edge platforms).
  • envFileOptional — When true, a path in envFilePath that does not exist is skipped instead of throwing. Defaults to false, which is the behaviour released in 0.1.0. Only ABSENCE is tolerated: a file that exists and cannot be read still throws. This is what a scaffolded project uses, because the dotenv file the CLI emits is gitignored and therefore absent on every fresh clone, in CI, and in a container built from the repository.
  • validationSchema — A Zod-compatible schema for startup validation. The schema’s parse() is called once after merging and expansion; the parsed output is stored as the configuration snapshot, preserving Zod coercions and defaults.
  • sections — Typed sections validated after validationSchema has parsed the whole snapshot. Each section reads only its declared prefix-plus-key entries, parses them once at startup, and caches the output for getConfigSection. Sections validate even when instance is supplied.
  • expandVariables — When true (default), expand ${NAME} references in values using the final merged configuration.
  • instance — An already-loaded snapshot to register verbatim. Present → nothing is read from the environment or from disk and envFilePath, envFileOptional, validationSchema, and expandVariables are ignored; declared sections still validate it through named reads. Absent → configuration loads normally. This exists so an application can resolve configuration before its plugins are constructed and then hand the plugin that same object, rather than letting it load a second snapshot a moment later that the composition never saw.
  • diagnostics — The opt-in value-free provenance policy, below.

loadConfig()

import { loadConfig } from '@setu-ts/config-plugin';
import { createRuntimeServices } from '@setu-ts/runtime';

const config = await loadConfig(createRuntimeServices(), {
  envFilePath: ['.env.local', '.env'],
});
const port = config.get<number>('PORT', { default: 3000 });

(runtime: IRuntimeServices, options?: ConfigPluginOptions) => Promise<IConfig> — the same implementation ConfigPlugin.register uses, reachable without an application. Use it when configuration must be read before any plugin exists (choosing which plugins to register, for instance); inside an application, resolve CAPABILITIES.CONFIG from the registry instead.

Merging, expansion, and validation behave identically on both paths because there is only one: the plugin delegates to this function and registers what it returns. Pass the result back as ConfigPlugin({ instance: config }) so the application holds exactly one snapshot — createFullStackAppFromConfig does precisely that.

StructuralSchema<T>

interface StructuralSchema<T> {
  parse(input: unknown): T;
}

Minimal schema interface compatible with Zod’s parse(unknown) API. Consumers supply a Zod schema without config-plugin depending on Zod.

Typed Configuration Sections

import { CAPABILITIES, type IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';

const database = defineConfigSection({
  prefix: 'DATABASE_',
  keys: ['URL', 'POOL_SIZE'],
  schema: z.object({
    URL: z.string().url(),
    POOL_SIZE: z.coerce.number().int().positive(),
  }),
});

app.register(ConfigPlugin({ sections: [database] }));

const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database);
// settings.POOL_SIZE is a validated number.

ConfigSection<T> is the typed declaration returned by defineConfigSection. prefix is prepended to every prefix-stripped entry in keys, so the example reads DATABASE_URL and DATABASE_POOL_SIZE while the schema receives { URL, POOL_SIZE }. The explicit key list is required because IConfig intentionally supports named reads but no key enumeration; this also makes an arbitrary ConfigPluginOptions.instance validatable. Missing keys are omitted and the schema decides whether they are optional. A section schema is not reusable as validationSchema: the latter receives the entire flat snapshot and always runs first.

getConfigSection(config, section) returns the exact schema output cached during startup. It throws when that configuration snapshot did not validate the given definition; it never parses a value at read time.

Configuration Precedence

Values are merged in the following order (highest precedence first):

  1. Environment variables (runtime.env)
  2. Earlier file paths (.env.local overrides .env)
  3. Later file paths

undefined entries in runtime.env are filtered out.

Variable references are expanded only after all sources have been merged, so file values may reference runtime.env, runtime values may reference files, and references may cross file boundaries. Cycles or missing references fail startup unless expansion is disabled.

Dotenv Parsing

Configured files use strict parsing. Blank lines, comments, optional export prefixes, quoted and unquoted values, common double-quoted escapes, empty values, and inline comments are supported. Malformed entries, invalid keys, and unterminated quotes fail startup with a line number but do not include rejected values in the error message.

Schema validation failures use a stable, value-free error message so validator diagnostics cannot leak configuration secrets.

Edge Runtimes

On edge platforms where runtime.fs is undefined, envFilePath must not be set. Attempting to do so throws a clear startup error.

Hot Reload

Deferred. Configuration is an immutable application-startup snapshot.

Configuration Provenance (0.8.0)

ConfigDiagnosticsOptions is the explicit opt-in: enabled is the LITERAL true (an absent option is the disabled path; any other value is refused, with fixed messages that never echo a value), keys is the exact configuration-key to display-alias allowlist (at most 128 entries, each alias unique, 1–64 UTF-8 bytes, no control characters), and files optionally allows exact configured .env paths as source aliases (at most eight, same alias rules). When present, the loader records — during the ONE load it already performs — where each approved key’s final value was observed to come from: the source category and approved source aliases, the evidenced precedence displacement (overriddenSourceAliases, lowest first), the ${NAME} expansion evidence (expanded plus the approved referenceAliases, at most 16 per key), and the schema effect derived only from input/output property presence (validated / introduced / removed / not-configured; introduced reports the appearance and never names a mechanism, since a schema default and a transform are indistinguishable by presence).

No configuration value, value hash, value length, raw key name, or file path is ever retained or served. Unapproved keys are never observed — no entry, and no counter disclosing that they exist; an unapproved file path contributes only the category file. loadConfig(runtime, { diagnostics }) builds the record with the snapshot; ConfigPlugin({ instance, diagnostics }) adopts that exact instance’s record (keeping each entry’s real environment/file origin — injection is how the snapshot reached the application, not where its values came from) or, for an opaque injected instance, reports every approved alias with origin and schema effect unknown and NO presence flag, without a single read of it. An adopted record is served exactly as the loader built it, under the loadConfig call’s approvals — the plugin’s own keys and files only enable the source and govern the opaque case, so the same policy belongs on both calls. Provenance adds no read to any configuration object: configured sections perform exactly the same IConfig.get calls with diagnostics disabled and enabled.

The plugin ALWAYS registers the source under the eager CAPABILITIES.CONFIG_DIAGNOSTICS token: absent diagnostics answers disabled; enabled with no approved resolved entries answers no-data; otherwise ready. The Diagnostics Connector serves it at GET /v1/config (see Diagnostics Connector and docs/diagnostics-protocol.md); snapshots are deeply frozen and bounded by the connector’s 256 KiB response ceiling.

Exports

ExportKind
ConfigPluginfunction
ConfigPluginOptionsinterface
loadConfigfunction
StructuralSchemainterface
defineConfigSectionfunction
getConfigSectionfunction
ConfigSectioninterface
ConfigDiagnosticsOptionsinterface

Agent card · TypeDoc API reference