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

@neststack/config

v0.1.0

Published

Enterprise-grade, type-safe dynamic configuration module for NestJS with Zod validation, secret masking, and O(1) lookups

Readme

@neststack/config

Enterprise-grade, type-safe dynamic configuration module for NestJS. Designed for banking, financial services, and mission-critical applications requiring strict validation, secret management, runtime immutability, and auditable configuration provenance.


Table of Contents


Features

| Capability | Description | | ----------------------- | --------------------------------------------------------------------------- | | Dynamic Module | forRoot(), forRootAsync(), forFeature() following NestJS conventions | | Zod Validation | Schema-based validation with structured error messages at startup | | Type-Safe Access | Recursive Path<T> / PathValue<T, P> types for dot-notation autocomplete | | Secret Masking | Declared secret keys are masked as ******** in logs and diagnostics | | Deep Freeze | All configuration objects are recursively frozen after validation | | O(1) Lookup | Pre-compiled flat key map for constant-time access by dot-path | | Typed Env Loaders | getString, getNumber, getBoolean with clear error messages | | Config Overrides | Per-namespace overrides for testing and environment profiles | | Diagnostics | explain() traces where each value came from (loader, default, override) | | Namespace Isolation | Each config has a unique namespace; collisions are detected at startup | | Zero Runtime Deps | Only NestJS peer dependencies and Zod -- no transitive supply chain risk |


Installation

pnpm add @neststack/config zod

Peer Dependencies

Ensure your application has the following installed:

pnpm add @nestjs/common @nestjs/core rxjs

| Peer Dependency | Required Version | | ---------------- | ---------------------- | | @nestjs/common | ^10.0.0 \|\| ^11.0.0 | | @nestjs/core | ^10.0.0 \|\| ^11.0.0 | | rxjs | ^7.0.0 | | zod | ^3.20.0 \|\| ^4.0.0 |


Architecture Overview

                                NestStackConfigModule
                               ┌─────────────────────────────────┐
                               │                                 │
  forRoot(options) ──────────► │  ┌───────────┐   ┌───────────┐  │
                               │  │ EnvSource │──►│  Loaders   │  │
  forRootAsync(options) ─────► │  └───────────┘   └─────┬─────┘  │
                               │                        │        │
  forFeature(configs...) ────► │                  ┌─────▼─────┐  │
                               │                  │ ConfigStore│  │
                               │                  │            │  │
                               │                  │ ┌────────┐ │  │
                               │                  │ │Validate│ │  │
                               │                  │ │(Zod)   │ │  │
                               │                  │ ├────────┤ │  │
                               │                  │ │Freeze  │ │  │
                               │                  │ ├────────┤ │  │
                               │                  │ │Lookup  │ │  │
                               │                  │ │Map O(1)│ │  │
                               │                  │ └────────┘ │  │
                               │                  └─────┬─────┘  │
                               │                        │        │
                               │                  ┌─────▼──────┐ │
                               │                  │ConfigService│ │
                               │                  │ get()       │ │
                               │                  │ namespace() │ │
                               │                  │ explain()   │ │
                               │                  │ printSafe() │ │
                               │                  └────────────┘ │
                               └─────────────────────────────────┘

Data flow:

  1. Configuration definitions are registered via forRoot() or forFeature()
  2. Each definition's load() function is invoked with an EnvSource context
  3. Returned data is merged with any overrides
  4. The merged object is validated against the Zod schema
  5. Validated data is deep-frozen for immutability
  6. A flat lookup map is built for O(1) access by dot-path
  7. ConfigService is made available to the entire application

Quick Start

1. Define your configuration

// src/config/database.config.ts
import { defineConfig } from '@neststack/config';
import { z } from 'zod';

export const databaseConfig = defineConfig({
  namespace: 'database',
  schema: z.object({
    host: z.string(),
    port: z.number().default(5432),
    name: z.string(),
    password: z.string(),
    ssl: z.boolean().default(true),
    poolSize: z.number().min(1).max(100).default(10),
  }),
  load: ({ env }) => ({
    host: env.getString('DB_HOST'),
    port: env.getNumber('DB_PORT', 5432),
    name: env.getString('DB_NAME'),
    password: env.getString('DB_PASSWORD'),
    ssl: env.getBoolean('DB_SSL', true),
    poolSize: env.getNumber('DB_POOL_SIZE', 10),
  }),
  secretKeys: ['password'],
});

2. Register in your root module

// src/app.module.ts
import { Module } from '@nestjs/common';
import { NestStackConfigModule } from '@neststack/config';
import { databaseConfig } from './config/database.config';

@Module({
  imports: [
    NestStackConfigModule.forRoot({
      configs: [databaseConfig],
    }),
  ],
})
export class AppModule {}

3. Inject and use

// src/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@neststack/config';

@Injectable()
export class DatabaseService {
  constructor(private readonly config: ConfigService) {}

  getConnectionString(): string {
    const host = this.config.get('database.host' as any);
    const port = this.config.get('database.port' as any);
    const name = this.config.get('database.name' as any);
    return `postgres://${host}:${port}/${name}`;
  }
}

Defining Configuration

defineConfig()

defineConfig() creates a frozen, immutable ConfigDefinition object. It validates the inputs at call time and freezes the result to prevent accidental mutation.

import { defineConfig } from '@neststack/config';
import { z } from 'zod';

const config = defineConfig({
  namespace: 'app',        // unique identifier (required)
  schema: z.object({...}), // Zod schema (required)
  load: ({ env }) => ({    // loader function (optional)
    ...
  }),
  secretKeys: ['apiKey'],  // keys to mask in logs (optional)
});

| Option | Type | Required | Description | | ------------ | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | namespace | string | Yes | Unique identifier for this config block. Used as the prefix in dot-paths (e.g., database.url). | | schema | ZodSchema | Yes | Zod schema that validates the configuration. Provides defaults, type coercion, and constraints. | | load | (ctx: LoadContext) => Partial<T> | No | Loader function that produces the raw configuration from environment variables, secrets, or files. | | secretKeys | string[] | No | Top-level keys within this namespace that contain sensitive data. These are masked in printSafe() and flagged in explain(). |

The returned ConfigDefinition is frozen (Object.isFrozen(config) === true) and its secretKeys array is also frozen.

Configuration Schemas with Zod

Zod schemas serve three purposes:

  1. Validation -- the loaded configuration is validated at application startup. Invalid configuration causes the application to fail fast with a structured error.
  2. Defaults -- Zod's .default() provides fallback values when the loader omits a key.
  3. Type inference -- TypeScript infers the shape of your configuration from the schema.
const schema = z.object({
  host: z.string().min(1),
  port: z.number().int().min(1).max(65535).default(5432),
  ssl: z.boolean().default(true),
  maxRetries: z.number().min(0).max(10).default(3),
  connectionTimeout: z.number().min(100).max(30000).default(5000),
  tags: z.array(z.string()).default([]),
  mode: z.enum(['read', 'write', 'read-write']).default('read-write'),
});

Validation errors are structured and actionable:

Config validation failed for namespace "database":
  database.host: String must contain at least 1 character(s)
  database.port: Number must be less than or equal to 65535

Configuration Loaders

The load function receives a LoadContext with typed accessors for environment variables, secrets, and files.

interface LoadContext {
  env: IEnvSource; // typed environment variable access
  secrets?: SecretSource; // async secret store (Vault, KMS, etc.)
  files?: FileSource; // file-based config (JSON, YAML, text)
}

A loader returns a Partial<T> of the schema's inferred type. Missing keys will fall through to Zod defaults.

const config = defineConfig({
  namespace: 'auth',
  schema: z.object({
    issuer: z.string().url(),
    clientId: z.string(),
    audience: z.string().optional(),
    tokenExpiry: z.number().default(3600),
  }),
  load: ({ env }) => ({
    issuer: env.getString('OIDC_ISSUER'),
    clientId: env.getString('OIDC_CLIENT_ID'),
    audience: env.getOptionalString('OIDC_AUDIENCE'),
    // tokenExpiry not loaded -- Zod default (3600) applies
  }),
});

When no loader is provided, all values come from Zod defaults:

const featureFlags = defineConfig({
  namespace: 'features',
  schema: z.object({
    darkMode: z.boolean().default(false),
    betaProgram: z.boolean().default(false),
    maxUploadSizeMb: z.number().default(50),
  }),
  // no load function -- all defaults
});

Secret Key Declaration

Secret keys are declared as top-level property names within a namespace. They affect two behaviors:

  1. printSafe() masks their values as ******** in log output.
  2. explain() sets isSecret: true in the returned diagnostics.
const config = defineConfig({
  namespace: 'database',
  schema: z.object({
    url: z.string(),
    password: z.string(),
    sslCert: z.string(),
  }),
  load: ({ env }) => ({
    url: env.getString('DB_URL'),
    password: env.getString('DB_PASSWORD'),
    sslCert: env.getString('DB_SSL_CERT'),
  }),
  secretKeys: ['password', 'sslCert'],
});

Module Registration

forRoot() -- Synchronous

Use forRoot() when all configuration can be resolved synchronously at startup. This is the most common pattern.

NestStackConfigModule.forRoot({
  configs: [databaseConfig, authConfig, cacheConfig],
  isGlobal: true, // default: true -- available to all modules
});

| Option | Type | Default | Description | | ----------- | ----------------------------------------- | ------------- | --------------------------------------------------------------------------- | | configs | ConfigDefinition[] | -- | Array of config definitions created with defineConfig() | | envSource | Record<string, string \| undefined> | process.env | Override the environment variable source. Useful for testing. | | overrides | Record<string, Record<string, unknown>> | -- | Per-namespace value overrides. Applied after loading, before validation. | | isGlobal | boolean | true | When true, ConfigService is available in all modules without importing. | | strict | boolean | -- | Reserved for future use. | | cache | boolean | -- | Reserved for future use. |

Multiple configs in a single forRoot:

NestStackConfigModule.forRoot({
  configs: [databaseConfig, redisConfig, authConfig, loggingConfig],
});

forRootAsync() -- Asynchronous

Use forRootAsync() when configuration depends on other providers (e.g., a secret store, an HTTP client, or another NestJS service).

Using useFactory:

NestStackConfigModule.forRootAsync({
  imports: [VaultModule],
  useFactory: (vault: VaultService) => ({
    configs: [databaseConfig, authConfig],
    envSource: process.env,
  }),
  inject: [VaultService],
});

Using useClass:

@Injectable()
class ConfigFactory implements NestStackConfigOptionsFactory {
  constructor(private readonly vault: VaultService) {}

  async createNestStackConfigOptions(): Promise<NestStackConfigModuleOptions> {
    await this.vault.authenticate();
    return {
      configs: [databaseConfig],
    };
  }
}

NestStackConfigModule.forRootAsync({
  imports: [VaultModule],
  useClass: ConfigFactory,
});

Using useExisting:

// Reuses an already-registered factory provider
NestStackConfigModule.forRootAsync({
  imports: [SharedModule],
  useExisting: ConfigFactory,
});

forFeature() -- Feature Modules

Use forFeature() to register additional configuration namespaces from feature modules. Feature configs are added to the same global store and become accessible through ConfigService.

forFeature() accepts both defineConfig() output and plain options objects, allowing inline definitions without a separate defineConfig() call.

With pre-built definitions:

// payments/payments.module.ts
@Module({
  imports: [NestStackConfigModule.forFeature(paymentsConfig)],
})
export class PaymentsModule {}

With inline plain options:

@Module({
  imports: [
    NestStackConfigModule.forFeature({
      namespace: 'notifications',
      schema: z.object({
        emailFrom: z.string().email().default('[email protected]'),
        smsEnabled: z.boolean().default(false),
        retryAttempts: z.number().default(3),
      }),
    }),
  ],
})
export class NotificationsModule {}

Mixing both forms:

NestStackConfigModule.forFeature(paymentsConfig, {
  namespace: 'audit',
  schema: z.object({
    enabled: z.boolean().default(true),
    retentionDays: z.number().default(365),
  }),
});

With a loader:

NestStackConfigModule.forFeature({
  namespace: 'redis',
  schema: z.object({
    url: z.string().url(),
    password: z.string(),
    db: z.number().default(0),
  }),
  load: ({ env }) => ({
    url: env.getString('REDIS_URL'),
    password: env.getString('REDIS_PASSWORD'),
    db: env.getNumber('REDIS_DB', 0),
  }),
  secretKeys: ['password'],
});

Using Configuration at Runtime

ConfigService

ConfigService is the primary interface for accessing configuration at runtime. It is injected via NestJS dependency injection and is available globally when isGlobal: true (the default).

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@neststack/config';

@Injectable()
export class MyService {
  constructor(private readonly config: ConfigService) {}
}

ConfigService is generic. You can parameterize it for stronger typing:

interface AppConfig {
  database: { host: string; port: number; password: string };
  auth: { issuer: string; clientId: string };
}

@Injectable()
export class MyService {
  constructor(private readonly config: ConfigService<AppConfig>) {}

  connect() {
    // TypeScript auto-completes valid paths
    const host = this.config.get('database.host');
    // host is inferred as `string`
  }
}

Dot-Notation Path Access

ConfigService.get(path) retrieves a single value using dot-notation. Lookups execute in O(1) time via a pre-compiled flat map.

const host = config.get('database.host' as any); // string
const port = config.get('database.port' as any); // number
const ssl = config.get('database.ssl' as any); // boolean
const issuer = config.get('auth.issuer' as any); // string

Error on missing keys:

config.get('nonexistent.key' as any);
// throws: Configuration key "nonexistent.key" does not exist

Namespace Access

ConfigService.namespace(name) returns the entire frozen config object for a namespace.

const dbConfig = config.namespace('database' as any);
// { host: 'localhost', port: 5432, name: 'mydb', password: 'secret', ssl: true, poolSize: 10 }

Object.isFrozen(dbConfig); // true

// Attempts to mutate throw a TypeError
dbConfig.host = 'other'; // TypeError: Cannot assign to read only property

This is useful when you need to pass an entire config object to a library or driver:

@Injectable()
export class RedisService implements OnModuleInit {
  private client: Redis;

  constructor(private readonly config: ConfigService) {}

  onModuleInit() {
    const redis = this.config.namespace('redis' as any);
    this.client = new Redis({
      host: redis.host,
      port: redis.port,
      password: redis.password,
    });
  }
}

Configuration Diagnostics

ConfigService.explain(path) returns a ConfigExplanation object that traces where a value came from. This is invaluable for debugging configuration issues in production.

const explanation = config.explain('database.password');

Returns:

{
  path: 'database.password',
  namespace: 'database',
  key: 'password',
  value: 'my-secret-password',
  source: 'loader',     // 'loader' | 'default' | 'override'
  isSecret: true
}

| Field | Type | Description | | ----------- | ------------------------------------- | ----------------------------------------- | | path | string | The full dot-path that was queried | | namespace | string | The namespace portion of the path | | key | string | The key portion within the namespace | | value | unknown | The actual runtime value | | source | 'loader' \| 'default' \| 'override' | Where the value came from | | isSecret | boolean | Whether this key was declared as a secret |

Source values:

  • 'loader' -- The value was populated by the load() function
  • 'default' -- The value fell through to a Zod schema default
  • 'override' -- The value was explicitly overridden via the overrides option

Use case -- health check endpoint:

@Controller('health')
export class HealthController {
  constructor(private readonly config: ConfigService) {}

  @Get('config')
  getConfigDiagnostics() {
    return {
      database: {
        host: this.config.explain('database.host'),
        port: this.config.explain('database.port'),
        ssl: this.config.explain('database.ssl'),
      },
    };
  }
}

Safe Printing

ConfigService.printSafe() logs the entire configuration tree to the NestJS logger with all secret values masked. Call this at application startup for audit logging.

@Injectable()
export class AppBootstrapService implements OnModuleInit {
  constructor(private readonly config: ConfigService) {}

  onModuleInit() {
    this.config.printSafe();
  }
}

Produces:

[ConfigStore] Configuration:
{
  "database": {
    "host": "db.production.internal",
    "port": 5432,
    "name": "banking_core",
    "password": "********",
    "ssl": true,
    "poolSize": 10
  },
  "auth": {
    "issuer": "https://sso.bank.com",
    "clientId": "core-api",
    "audience": "https://api.bank.com"
  }
}

Environment Variable Loading

EnvSource API

EnvSource is a typed wrapper around environment variables. It is automatically created by the module and injected into loader functions via the LoadContext.

| Method | Return Type | Description | | --------------------------- | ---------------------- | --------------------------------------------------------------- | | getString(key, default?) | string | Returns the value. Throws if missing and no default. | | getNumber(key, default?) | number | Parses to number. Throws if non-numeric or missing. | | getBoolean(key, default?) | boolean | Parses to boolean. Throws if invalid or missing. | | getOptionalString(key) | string \| undefined | Returns the value or undefined. Never throws. | | getOptionalNumber(key) | number \| undefined | Parses to number or returns undefined. Throws if non-numeric. | | getOptionalBoolean(key) | boolean \| undefined | Parses to boolean or returns undefined. Throws if invalid. |

Required vs Optional Variables

Required methods (getString, getNumber, getBoolean) throw descriptive errors when a variable is not set:

Environment variable DB_HOST is required but not set

Optional methods (getOptionalString, getOptionalNumber, getOptionalBoolean) return undefined when the variable is missing, allowing Zod defaults to take over:

load: ({ env }) => ({
  host: env.getString('DB_HOST'), // required -- throws if missing
  port: env.getNumber('DB_PORT', 5432), // required with default
  debugMode: env.getOptionalBoolean('DB_DEBUG'), // optional -- undefined if unset
});

Type Coercion

Numbers: The raw string is parsed with Number(). NaN values throw:

Environment variable DB_PORT must be a valid number, received: "not-a-number"

Booleans: The following string values are recognized (case-insensitive):

| Truthy | Falsy | | ------------------ | ------------------ | | true, 1, yes | false, 0, no |

Any other value throws:

Environment variable ENABLE_SSL must be a boolean (true/false/1/0/yes/no), received: "maybe"

Empty strings are treated as "not set" -- they will trigger defaults or throw for required variables, just like undefined.


Configuration Overrides

Overrides let you replace specific values after loading but before validation. They are keyed by namespace.

NestStackConfigModule.forRoot({
  configs: [databaseConfig, cacheConfig],
  overrides: {
    database: {
      host: 'localhost',
      ssl: false,
    },
    cache: {
      ttl: 60,
    },
  },
});

Override precedence: Zod defaults < loader values < overrides

Overrides are applied with a shallow merge per namespace:

// Loader returns:   { host: 'prod-db.internal', port: 5432, ssl: true }
// Override applies:  { host: 'localhost', ssl: false }
// Merged result:     { host: 'localhost', port: 5432, ssl: false }
// Then validated by Zod schema

Primary use cases:

  1. Test environments: Override production values for integration testing
  2. Environment profiles: Apply staging-specific settings
  3. Local development: Override without changing .env files

Security

Secret Masking

Any key listed in secretKeys is masked as ******** in all logging and safe-print operations. The actual value remains accessible through ConfigService.get() and ConfigService.namespace() at runtime.

const config = defineConfig({
  namespace: 'payments',
  schema: z.object({ apiKey: z.string(), webhookUrl: z.string() }),
  load: ({ env }) => ({
    apiKey: env.getString('STRIPE_API_KEY'),
    webhookUrl: env.getString('STRIPE_WEBHOOK_URL'),
  }),
  secretKeys: ['apiKey'],
});

printSafe() output:

{
  "payments": {
    "apiKey": "********",
    "webhookUrl": "https://api.example.com/webhooks/stripe"
  }
}

explain() flags secrets:

config.explain('payments.apiKey');
// { ..., isSecret: true, value: 'sk_live_...' }

Immutability via Deep Freeze

All validated configuration objects are recursively frozen using Object.freeze(). This prevents accidental (or malicious) mutation at runtime.

const db = config.namespace('database' as any);

db.host = 'hacked';
// TypeError: Cannot assign to read only property 'host' of object '#<Object>'

This guarantee extends to nested objects:

const nested = config.get('app.logging' as any);
nested.level = 'debug';
// TypeError: Cannot assign to read only property 'level'

Namespace Isolation

Each configuration definition must have a unique namespace. Attempting to register two definitions with the same namespace throws immediately at startup:

Configuration namespace "database" is already registered. Each namespace must be unique.

This prevents configuration from different modules from silently overwriting each other.


Performance

Configuration access is optimized for high-throughput applications:

  1. O(1) lookup -- At registration time, a flat Map<string, unknown> is built from the nested configuration. config.get('database.url') is a single map lookup, not recursive object traversal.

  2. Pre-compiled at startup -- All parsing, validation, freezing, and map building happens once during module initialization. Runtime access has zero parsing overhead.

  3. No serialization -- Values are stored in their native TypeScript types. No JSON parsing on each access.

  4. Frozen references -- Since config objects are frozen, the runtime can optimize property access knowing values never change.


Type System

The library provides two recursive utility types for type-safe dot-path access:

Path<T>

Produces a union of all valid dot-notation paths for a given type.

interface AppConfig {
  database: { host: string; port: number };
  auth: { issuer: string };
}

type ValidPaths = Path<AppConfig>;
// "database" | "database.host" | "database.port" | "auth" | "auth.issuer"

PathValue<T, P>

Resolves the value type at a given path.

type HostType = PathValue<AppConfig, 'database.host'>; // string
type PortType = PathValue<AppConfig, 'database.port'>; // number

Depth limit: Recursion stops at depth 5 to prevent TypeScript compiler slowdowns on deeply nested types.

Usage with ConfigService:

@Injectable()
export class MyService {
  constructor(private readonly config: ConfigService<AppConfig>) {}

  getHost(): string {
    return this.config.get('database.host'); // autocomplete + type inference
  }
}

Extensibility

SecretSource Interface

The SecretSource interface defines a contract for retrieving secrets from external stores (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, etc.). Concrete implementations belong in separate packages.

interface SecretSource {
  get(key: string): Promise<string>;
  getOptional(key: string): Promise<string | undefined>;
}

Planned usage:

load: async ({ env, secrets }) => ({
  host: env.getString('DB_HOST'),
  password: await secrets!.get('database/password'),
});

FileSource Interface

The FileSource interface defines a contract for loading configuration from files.

interface FileSource {
  json(path: string): unknown;
  yaml(path: string): unknown;
  text(path: string): string;
}

Custom EnvSource

You can implement IEnvSource for custom environment variable backends:

interface IEnvSource {
  getString(key: string, defaultValue?: string): string;
  getNumber(key: string, defaultValue?: number): number;
  getBoolean(key: string, defaultValue?: boolean): boolean;
  getOptionalString(key: string): string | undefined;
  getOptionalNumber(key: string): number | undefined;
  getOptionalBoolean(key: string): boolean | undefined;
}

Testing

Unit Testing with Overrides

Use the envSource and overrides options to inject test values without touching real environment variables.

const module = await Test.createTestingModule({
  imports: [
    NestStackConfigModule.forRoot({
      configs: [databaseConfig],
      envSource: {
        DB_HOST: 'localhost',
        DB_NAME: 'test_db',
        DB_PASSWORD: 'test-password',
      },
      overrides: {
        database: {
          poolSize: 1,
          ssl: false,
        },
      },
    }),
  ],
}).compile();

const config = module.get(ConfigService);
expect(config.get('database.host' as any)).toBe('localhost');
expect(config.get('database.poolSize' as any)).toBe(1);
expect(config.get('database.ssl' as any)).toBe(false);

Integration Testing

Test the full NestJS module bootstrap with Test.createTestingModule:

import { Test } from '@nestjs/testing';
import { NestStackConfigModule, ConfigService } from '@neststack/config';

describe('AppModule integration', () => {
  it('should bootstrap with valid configuration', async () => {
    const module = await Test.createTestingModule({
      imports: [
        NestStackConfigModule.forRoot({
          configs: [databaseConfig, authConfig],
          envSource: {
            DB_HOST: 'localhost',
            DB_NAME: 'test',
            DB_PASSWORD: 'pw',
            OIDC_ISSUER: 'https://auth.test.com',
            OIDC_CLIENT_ID: 'test-client',
          },
        }),
      ],
    }).compile();

    const config = module.get(ConfigService);
    expect(config.namespace('database' as any)).toBeDefined();
    expect(config.namespace('auth' as any)).toBeDefined();
  });

  it('should fail fast with invalid configuration', () => {
    expect(() =>
      NestStackConfigModule.forRoot({
        configs: [databaseConfig],
        envSource: { DB_HOST: '', DB_NAME: '', DB_PASSWORD: '' },
      }),
    ).toThrow('Config validation failed');
  });
});

Resetting State Between Tests

NestStackConfigModule.reset() clears the internal store between test runs. Call it in beforeEach:

import { NestStackConfigModule } from '@neststack/config';

beforeEach(() => {
  NestStackConfigModule.reset();
});

This is necessary because the ConfigStore is a static singleton. Without resetting, namespaces from one test will collide with the next.


Real-World Use Cases

Multi-Database Banking Application

// config/primary-db.config.ts
export const primaryDbConfig = defineConfig({
  namespace: 'primaryDb',
  schema: z.object({
    url: z.string().url(),
    password: z.string(),
    ssl: z.boolean().default(true),
    poolSize: z.number().min(5).max(50).default(20),
    statementTimeout: z.number().default(30000),
    idleTimeout: z.number().default(10000),
  }),
  load: ({ env }) => ({
    url: env.getString('PRIMARY_DB_URL'),
    password: env.getString('PRIMARY_DB_PASSWORD'),
    ssl: env.getBoolean('PRIMARY_DB_SSL', true),
    poolSize: env.getNumber('PRIMARY_DB_POOL', 20),
  }),
  secretKeys: ['password'],
});

// config/audit-db.config.ts
export const auditDbConfig = defineConfig({
  namespace: 'auditDb',
  schema: z.object({
    url: z.string().url(),
    password: z.string(),
    ssl: z.boolean().default(true),
    poolSize: z.number().min(2).max(10).default(5),
  }),
  load: ({ env }) => ({
    url: env.getString('AUDIT_DB_URL'),
    password: env.getString('AUDIT_DB_PASSWORD'),
  }),
  secretKeys: ['password'],
});

// app.module.ts
@Module({
  imports: [
    NestStackConfigModule.forRoot({
      configs: [primaryDbConfig, auditDbConfig],
    }),
  ],
})
export class AppModule {}

Microservice with Feature Flags

// Feature flags registered inline without defineConfig()
@Module({
  imports: [
    NestStackConfigModule.forFeature({
      namespace: 'features',
      schema: z.object({
        instantPayments: z.boolean().default(false),
        newOnboarding: z.boolean().default(false),
        aiRiskScoring: z.boolean().default(false),
        maxTransactionLimit: z.number().default(10000),
      }),
      load: ({ env }) => ({
        instantPayments: env.getOptionalBoolean('FF_INSTANT_PAYMENTS'),
        newOnboarding: env.getOptionalBoolean('FF_NEW_ONBOARDING'),
        aiRiskScoring: env.getOptionalBoolean('FF_AI_RISK'),
        maxTransactionLimit: env.getOptionalNumber('FF_MAX_TXN_LIMIT'),
      }),
    }),
  ],
})
export class FeatureModule {}

// Usage
@Injectable()
export class PaymentService {
  constructor(private readonly config: ConfigService) {}

  async processPayment(amount: number) {
    const maxLimit = this.config.get('features.maxTransactionLimit' as any);
    if (amount > maxLimit) {
      throw new BadRequestException(`Amount exceeds limit of ${maxLimit}`);
    }

    const useInstant = this.config.get('features.instantPayments' as any);
    if (useInstant) {
      return this.processInstantPayment(amount);
    }
    return this.processStandardPayment(amount);
  }
}

Async Initialization from a Vault

@Injectable()
class VaultConfigFactory implements NestStackConfigOptionsFactory {
  constructor(private readonly vault: VaultService) {}

  async createNestStackConfigOptions(): Promise<NestStackConfigModuleOptions> {
    const dbPassword = await this.vault.readSecret('database/password');
    const apiKey = await this.vault.readSecret('payments/api-key');

    return {
      configs: [
        defineConfig({
          namespace: 'database',
          schema: z.object({
            host: z.string(),
            port: z.number().default(5432),
            password: z.string(),
          }),
          load: ({ env }) => ({
            host: env.getString('DB_HOST'),
            port: env.getNumber('DB_PORT', 5432),
            password: dbPassword,
          }),
          secretKeys: ['password'],
        }),
        defineConfig({
          namespace: 'payments',
          schema: z.object({ apiKey: z.string(), currency: z.string().default('USD') }),
          load: () => ({ apiKey }),
          secretKeys: ['apiKey'],
        }),
      ],
    };
  }
}

@Module({
  imports: [
    NestStackConfigModule.forRootAsync({
      imports: [VaultModule],
      useClass: VaultConfigFactory,
    }),
  ],
})
export class AppModule {}

API Reference

Exports

| Export | Kind | Description | | ------------------------- | -------- | ----------------------------------------------------------------- | | NestStackConfigModule | Class | Dynamic module with forRoot(), forRootAsync(), forFeature() | | ConfigService<T> | Class | Injectable service for accessing configuration | | ConfigStore | Class | Internal store (also injectable via CONFIG_STORE token) | | defineConfig(options) | Function | Creates a frozen ConfigDefinition | | EnvSource | Class | Typed process.env wrapper implementing IEnvSource | | NESTSTACK_CONFIG_OPTIONS | Symbol | Injection token for module options | | CONFIG_STORE | Symbol | Injection token for the config store |

Type Exports

| Export | Kind | Description | | ---------------------------------- | --------- | ------------------------------------------------------ | | IEnvSource | Interface | Contract for typed environment variable access | | LoadContext | Interface | Context passed to loader functions | | SecretSource | Interface | Contract for async secret retrieval | | FileSource | Interface | Contract for file-based config loading | | ConfigLoader<S> | Type | Signature of a loader function | | ConfigDefinitionOptions | Interface | Input options for defineConfig() | | ConfigDefinition | Interface | Frozen output of defineConfig() | | ConfigDefinitionInput | Type | Union of ConfigDefinition \| ConfigDefinitionOptions | | ConfigExplanation | Interface | Return type of explain() | | NestStackConfigModuleOptions | Interface | Options for forRoot() | | NestStackConfigModuleAsyncOptions | Interface | Options for forRootAsync() | | NestStackConfigOptionsFactory | Interface | Factory interface for useClass / useExisting | | Path<T> | Type | Union of valid dot-notation paths for T | | PathValue<T, P> | Type | Value type at path P in T |

ConfigService Methods

| Method | Signature | Description | | ----------- | ------------------------------------------ | -------------------------------------------------------------- | | get | get<P>(path: P): PathValue<T, P> | O(1) value lookup by dot-path. Throws if not found. | | namespace | namespace<K>(name: K): T[K] | Returns the frozen namespace object. Throws if not registered. | | explain | explain(path: string): ConfigExplanation | Diagnostics for a specific path. | | printSafe | printSafe(): void | Logs all configuration with secrets masked. |

EnvSource Methods

| Method | Signature | Description | | -------------------- | ----------------------------------------------- | ------------------------------------------------------- | | getString | getString(key, default?): string | Returns string value. Throws if missing and no default. | | getNumber | getNumber(key, default?): number | Parses to number. Throws if non-numeric or missing. | | getBoolean | getBoolean(key, default?): boolean | Parses to boolean. Throws if invalid or missing. | | getOptionalString | getOptionalString(key): string \| undefined | Returns value or undefined. | | getOptionalNumber | getOptionalNumber(key): number \| undefined | Parses or returns undefined. Throws if non-numeric. | | getOptionalBoolean | getOptionalBoolean(key): boolean \| undefined | Parses or returns undefined. Throws if invalid. |


Best Practices

1. Fail fast on invalid configuration

Always define required fields without Zod defaults. The application should crash at startup if critical configuration is missing -- not at the first request.

// Good: required fields fail fast
schema: z.object({
  url: z.string().url(), // no default -- must be provided
  port: z.number().default(5432), // safe default
});

// Bad: hiding required config behind defaults
schema: z.object({
  url: z.string().default(''), // will pass validation but fail at runtime
});

2. One namespace per bounded context

Align namespaces with domain boundaries:

// Good
defineConfig({ namespace: 'database', ... })
defineConfig({ namespace: 'auth', ... })
defineConfig({ namespace: 'payments', ... })

// Bad: monolithic config
defineConfig({ namespace: 'app', schema: z.object({
  dbUrl: ..., dbPassword: ..., authIssuer: ..., stripeKey: ...
}) })

3. Always declare secret keys

Any value that should not appear in logs, error messages, or monitoring dashboards must be listed in secretKeys:

secretKeys: ['password', 'apiKey', 'privateKey', 'token', 'secret'];

4. Use Zod constraints for validation

Leverage Zod's rich validation API to catch misconfigurations at startup:

schema: z.object({
  port: z.number().int().min(1).max(65535),
  retries: z.number().int().min(0).max(10),
  timeout: z.number().min(100).max(60000),
  url: z.string().url(),
  email: z.string().email(),
  mode: z.enum(['development', 'staging', 'production']),
  logLevel: z.enum(['debug', 'info', 'warn', 'error']),
});

5. Use overrides for testing, not mocked env vars

// Good: deterministic, explicit
NestStackConfigModule.forRoot({
  configs: [databaseConfig],
  envSource: { DB_HOST: 'localhost', DB_PASSWORD: 'test' },
  overrides: { database: { poolSize: 1 } },
});

// Bad: modifying global state
process.env.DB_HOST = 'localhost';

6. Print config at startup

Call printSafe() during bootstrap for operational visibility and audit compliance:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config = app.get(ConfigService);
  config.printSafe();
  await app.listen(3000);
}

7. Use forFeature for domain-specific configs

Keep root-level config minimal. Let feature modules own their configuration:

// app.module.ts -- only core infrastructure config
NestStackConfigModule.forRoot({
  configs: [databaseConfig, loggingConfig],
});

// payments/payments.module.ts -- owns its own config
NestStackConfigModule.forFeature(paymentsConfig);

// notifications/notifications.module.ts
NestStackConfigModule.forFeature(notificationsConfig);

8. Reset between tests

Always call NestStackConfigModule.reset() in beforeEach to avoid state leakage:

beforeEach(() => {
  NestStackConfigModule.reset();
});

Further Reading


License

MIT