Config Package

The @hazeljs/config package provides a robust configuration management system for your HazelJS applications. It supports loading from environment variables, .env files, custom loaders, and schema validation.

Purpose

Managing application configuration across different environments (development, staging, production) is a common challenge. You need to load values from multiple sources, validate required settings, provide defaults, and ensure type safety. The @hazeljs/config package solves these problems by providing:

  • Multi-Source Loading: Load from environment variables, .env files, and custom sources
  • Schema Validation: Validate configuration at startup to catch errors early
  • Type Safety: TypeScript-first design with proper type inference
  • Nested Configuration: Support for nested config objects with dot notation
  • Environment-Specific: Easy management of different configs per environment

Architecture

The package uses a layered loading approach that prioritizes sources and validates configuration:

Loading diagram...

Key Features

  1. Layered Loading: Loads from multiple sources with priority ordering
  2. Validation: Schema-based validation ensures required config is present
  3. Type Safety: Full TypeScript support with type inference
  4. Global Access: Optional global configuration for easy access

Advantages

1. Early Error Detection

Schema validation catches missing or invalid configuration at startup, preventing runtime errors.

2. Developer Experience

Simple API with config.get() and config.getOrThrow() makes accessing configuration intuitive.

3. Type Safety

TypeScript support ensures you get autocomplete and type checking for your configuration values.

4. Flexibility

Load from environment variables, files, databases, or custom sources. Mix and match as needed.

5. Environment Management

Easy to manage different configurations for development, staging, and production environments.

6. Nested Support

Access nested configuration with dot notation: config.get('database.host').

Installation

npm install @hazeljs/config

Quick Start

Basic Setup

import { HazelModule } from '@hazeljs/core';
import { ConfigModule } from '@hazeljs/config';

@HazelModule({
  imports: [ConfigModule.forRoot()],
})
export class AppModule {}

Configuration Service

The ConfigService is the core service for accessing configuration values throughout your application. It provides a type-safe, flexible API for reading configuration from multiple sources.

Understanding ConfigService

Purpose: Provides a unified interface for accessing configuration values from environment variables, .env files, and custom sources.

How it works:

  1. Multi-Source Loading: Loads from .env files, process.env, and custom loaders
  2. Priority Ordering: Later sources override earlier ones
  3. Type Safety: TypeScript generics ensure type-safe access
  4. Nested Access: Supports dot notation for nested configuration
  5. Validation: Optional schema validation at startup

Basic Usage

Inject ConfigService to access configuration values:

import { Injectable } from '@hazeljs/core';
import { ConfigService } from '@hazeljs/config';

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

  getApiUrl() {
    // config.get() with default value
    // Returns the configured value or the default if not found
    return this.config.get<string>('API_URL', 'http://localhost:3000');
    // Type parameter <string> ensures type safety
    // Default value provides fallback
  }

  getDatabaseUrl() {
    // config.getOrThrow() requires the value to exist
    // Throws error if configuration is missing
    return this.config.getOrThrow<string>('DATABASE_URL');
    // Use this for required configuration
    // Fails fast at startup if missing
  }

  checkFeatureFlag() {
    // Check if configuration exists
    if (this.config.has('FEATURE_NEW_UI')) {
      const enabled = this.config.get<boolean>('FEATURE_NEW_UI', false);
      return enabled;
    }
    return false;
  }
}

Configuration Methods Explained

1. get<T>(key: string, defaultValue?: T): T | undefined

Retrieves a configuration value with optional default:

// With default value
const port = this.config.get<number>('PORT', 3000);
// Returns configured PORT or 3000 if not found
// Type is inferred as number

// Without default (returns undefined if not found)
const apiKey = this.config.get<string>('API_KEY');
// Returns string | undefined
// Use when configuration is optional

2. getOrThrow<T>(key: string): T

Retrieves a required configuration value, throwing if missing:

const dbUrl = this.config.getOrThrow<string>('DATABASE_URL');
// Returns string (never undefined)
// Throws Error if DATABASE_URL is not configured
// Use for required configuration
// Fails fast at runtime if missing

3. has(key: string): boolean

Checks if a configuration key exists:

if (this.config.has('FEATURE_FLAG')) {
  // Configuration exists, safe to access
  const flag = this.config.get<boolean>('FEATURE_FLAG');
}

4. set(key: string, value: unknown): void

Dynamically sets a configuration value:

// Set configuration at runtime
this.config.set('CACHE_TTL', 3600);
this.config.set('feature.enabled', true);

// Useful for:
// - Runtime configuration updates
// - Feature flags
// - Dynamic settings

5. getAll(): Record<string, unknown>

Retrieves all configuration as an object:

const allConfig = this.config.getAll();
// Returns: { DATABASE_URL: '...', PORT: 3000, ... }
// Useful for debugging or configuration inspection

Environment Files

Load configuration from .env files:

ConfigModule.forRoot({
  envFilePath: '.env',
  // or multiple files
  envFilePath: ['.env', '.env.local'],
})

Ignore Environment Files

ConfigModule.forRoot({
  ignoreEnvFile: true, // Don't load .env files
  ignoreEnvVars: false, // Still load from process.env
})

Configuration Methods

Get Configuration Value

// Get with optional default
const port = this.config.get<number>('PORT', 3000);

// Get or throw if missing
const apiKey = this.config.getOrThrow<string>('API_KEY');

// Check if key exists
if (this.config.has('FEATURE_FLAG')) {
  const flag = this.config.get<boolean>('FEATURE_FLAG');
}

Nested Configuration

Access nested configuration using dot notation. This allows you to organize related configuration values logically.

How it works:

  • Dot notation (database.host) accesses nested properties
  • Framework traverses the configuration object
  • Returns undefined if any part of the path doesn't exist

Example with Detailed Explanation:

// Environment variables or .env file:
// DATABASE_HOST=localhost
// DATABASE_PORT=5432
// DATABASE_NAME=mydb
// DATABASE_SSL=true

// Or structured in .env:
// DATABASE__HOST=localhost  (double underscore for nesting)
// DATABASE__PORT=5432
// DATABASE__NAME=mydb

// Access nested configuration
const host = this.config.get<string>('database.host');
// Traverses: config.database.host
// Returns 'localhost' or undefined

const port = this.config.get<number>('database.port');
// Returns 5432 (as number) or undefined

const name = this.config.get<string>('database.name');
// Returns 'mydb' or undefined

// Deep nesting
const ssl = this.config.get<boolean>('database.ssl.enabled');
// Traverses: config.database.ssl.enabled

Nested Configuration Structure:

You can structure configuration hierarchically:

// .env file
DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__CREDENTIALS__USERNAME=admin
DATABASE__CREDENTIALS__PASSWORD=secret

// Access with dot notation
const host = this.config.get<string>('database.host');
const username = this.config.get<string>('database.credentials.username');
const password = this.config.get<string>('database.credentials.password');

Benefits of Nested Configuration:

  1. Organization: Group related settings together
  2. Namespace: Avoid naming conflicts
  3. Clarity: Makes configuration structure clear
  4. Type Safety: Can define interfaces for nested config

Set Configuration

Dynamically set configuration values:

this.config.set('CACHE_TTL', 3600);
this.config.set('feature.enabled', true);

Get All Configuration

const allConfig = this.config.getAll();
console.log(allConfig);

Schema Validation

Validate configuration against a schema:

import { ConfigModule } from '@hazeljs/config';

const validationSchema = {
  validate(config: Record<string, unknown>) {
    const errors: string[] = [];

    if (!config.DATABASE_URL) {
      errors.push('DATABASE_URL is required');
    }

    if (!config.JWT_SECRET) {
      errors.push('JWT_SECRET is required');
    }

    if (errors.length > 0) {
      return {
        error: new Error(errors.join(', ')),
        value: config,
      };
    }

    return { value: config };
  },
};

ConfigModule.forRoot({
  validationSchema,
  validationOptions: {
    abortEarly: false, // Collect all errors
    allowUnknown: true, // Allow extra keys
  },
})

Custom Loaders

Load configuration from custom sources:

ConfigModule.forRoot({
  load: [
    () => ({
      // Load from database
      DATABASE_URL: 'postgresql://localhost:5432/mydb',
    }),
    () => ({
      // Load from external API
      EXTERNAL_API_KEY: fetchApiKey(),
    }),
    () => ({
      // Load from file
      ...require('./config.json'),
    }),
  ],
})

Global Configuration

Make configuration available globally:

ConfigModule.forRoot({
  isGlobal: true, // Available in all modules without importing
})

Complete Example

import { HazelModule } from '@hazeljs/core';
import { ConfigModule, ConfigService } from '@hazeljs/config';
import { Injectable } from '@hazeljs/core';

// Module setup
@HazelModule({
  imports: [
    ConfigModule.forRoot({
      envFilePath: ['.env', '.env.local'],
      validationSchema: {
        validate(config) {
          const required = ['DATABASE_URL', 'JWT_SECRET'];
          const missing = required.filter(key => !config[key]);
          
          if (missing.length > 0) {
            return {
              error: new Error(`Missing required config: ${missing.join(', ')}`),
              value: config,
            };
          }
          
          return { value: config };
        },
      },
      isGlobal: true,
    }),
  ],
})
export class AppModule {}

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

  getConnectionString() {
    return this.config.getOrThrow<string>('DATABASE_URL');
  }

  getConfig() {
    return {
      host: this.config.get<string>('database.host', 'localhost'),
      port: this.config.get<number>('database.port', 5432),
      ssl: this.config.get<boolean>('database.ssl', false),
    };
  }
}

Environment Variables

Create a .env file:

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DATABASE_HOST=localhost
DATABASE_PORT=5432

# JWT
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=7d

# API
API_URL=https://api.example.com
API_KEY=your-api-key

# Feature Flags
FEATURE_NEW_UI=true
FEATURE_BETA=false

Best Practices

  1. Use validation: Always validate required configuration at startup.

  2. Environment-specific files: Use .env.local for local development overrides.

  3. Type safety: Use TypeScript types when getting configuration values.

  4. Sensitive data: Never commit .env files with secrets. Use environment variables in production.

  5. Default values: Provide sensible defaults for optional configuration.

  6. Nested config: Use dot notation for related configuration values.

What's Next?

  • Learn about Auth for JWT configuration
  • Explore Prisma for database configuration
  • Check out Cache for cache configuration