UNPKG

@snow-tzu/type-config

Version:

Core configuration management system with Spring Boot-like features

743 lines (563 loc) 21.6 kB
# Configuration File Management Guide This guide explains how to properly manage configuration files (YAML, JSON) in your application, especially when using TypeScript compilation and build processes. ## Table of Contents - [Overview](#overview) - [File Structure](#file-structure) - [The Build Problem](#the-build-problem) - [Solutions by Framework](#solutions-by-framework) - [Configuration Directory Resolution](#configuration-directory-resolution) - [Profile-Based Loading](#profile-based-loading) - [Advanced Configuration Features](#advanced-configuration-features) - [Troubleshooting](#troubleshooting) ## Overview Type Config loads configuration from files (YAML, JSON) at runtime. However, **TypeScript compilation and build tools often delete or don't copy these files to the output directory**, causing runtime errors. **This is critical**: Your application will fail to start if configuration files are not available at runtime. ## File Structure Recommended project structure: ``` your-project/ ├── src/ │ ├── config/ │ │ ├── application.yml # Base configuration │ │ ├── application-development.yml # Development overrides │ │ ├── application-production.yml # Production overrides │ │ └── application-staging.yml # Staging overrides │ ├── index.ts │ └── ... ├── dist/ # Build output │ ├── src/ │ │ ├── config/ # ⚠️ Config files MUST be wherever it's supposed to be. │ │ │ ├── application.yml │ │ │ └── ... │ │ └── ... └── package.json ``` ## The Build Problem ### Why Configuration Files Disappear 1. **TypeScript only compiles `.ts` files** - YAML/JSON files are ignored 2. **Build tools clear output directories** - `dist/` is often deleted before each build 3. **Watch mode doesn't track non-TS files** - Changes to config files aren't detected ### Symptoms ``` Error: ENOENT: no such file or directory, open 'dist/src/config/application.yml' Error: Required configuration property 'database.host' is missing ``` ## Solutions by Framework ### NestJS #### Solution 1: Configure nest-cli.json (Recommended) Add asset configuration to automatically copy config files during build: ```json { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true, "assets": [ { "include": "config/*.yml", "outDir": "dist/src" }, { "include": "config/*.json", "outDir": "dist/src" } ] } } ``` #### Solution 2: Package.json Scripts ```json { "scripts": { "copy:config": "mkdir -p dist/src/config && cp src/config/*.{yml,json} dist/src/config/", "build": "nest build && npm run copy:config", "start:dev": "npm run copy:config && nest start --watch", "start:prod": "node dist/src/main" } } ``` #### Configuration in Code ```typescript import { Module } from '@nestjs/common'; import { TypeConfigModule } from '@snow-tzu/type-config-nestjs'; import * as path from 'path'; @Module({ imports: [ TypeConfigModule.forRoot({ profile: process.env.NODE_ENV || 'development', // __dirname in compiled code = dist/src configDir: path.join(__dirname, 'config'), isGlobal: true, }), ] }) export class AppModule { } ``` ### Express #### Solution: Package.json Scripts ```json { "scripts": { "copy:config": "mkdir -p dist/config && cp config/*.{yml,json} dist/config/", "build": "tsc && npm run copy:config", "start": "npm run copy:config && node dist/index.js", "dev": "npm run copy:config && ts-node src/index.ts" } } ``` #### Configuration in Code ```typescript import express from 'express'; import { createTypeConfig } from '@snow-tzu/type-config-express'; import * as path from 'path'; const app = express(); const config = await createTypeConfig({ profile: process.env.NODE_ENV || 'development', // In development: ./config // In production: dist/config configDir: path.join(__dirname, '../config'), configClasses: [ServerConfig, DatabaseConfig] }); ``` ### Fastify #### Solution: Package.json Scripts ```json { "scripts": { "copy:config": "mkdir -p dist/config && cp config/*.{yml,json} dist/config/", "build": "tsc && npm run copy:config", "start": "npm run copy:config && node dist/index.js", "dev": "npm run copy:config && ts-node src/index.ts" } } ``` #### Configuration in Code ```typescript import Fastify from 'fastify'; import { fastifyTypeConfig } from '@snow-tzu/type-config-fastify'; import * as path from 'path'; const fastify = Fastify(); await fastify.register(fastifyTypeConfig, { profile: process.env.NODE_ENV || 'development', configDir: path.join(__dirname, '../config'), configClasses: [ServerConfig, DatabaseConfig] }); ``` ### Vanilla Node.js / TypeScript #### Solution: Package.json Scripts ```json { "scripts": { "copy:config": "mkdir -p dist/config && cp config/*.{yml,json} dist/config/", "build": "tsc && npm run copy:config", "start": "npm run copy:config && node dist/index.js" } } ``` #### Configuration in Code ```typescript import { ConfigurationBuilder } from '@snow-tzu/type-config'; import * as path from 'path'; const { configManager, container } = await new ConfigurationBuilder() .withProfile(process.env.NODE_ENV || 'development') .withConfigDir(path.join(__dirname, '../config')) .registerConfig(ServerConfig) .build(); ``` ## Configuration Directory Resolution ### Understanding __dirname The `__dirname` variable points to different locations depending on execution context: | Context | __dirname Value | Config Location | |--------------------------|------------------------|--------------------------------------| | Development (ts-node) | `src/` | `src/config/` | | Development (nest start) | `dist/src/` | `dist/src/config/` | | Production (compiled) | `dist/` or `dist/src/` | `dist/config/` or `dist/src/config/` | ### Recommended Patterns #### Pattern 1: Relative to Compiled File (Recommended) ```typescript import * as path from 'path'; const configDir = path.join(__dirname, 'config'); // Development: dist/src/config // Production: dist/src/config ``` **Pros**: Works consistently in both dev and prod **Cons**: Requires config files in dist/src/config #### Pattern 2: Relative to Project Root ```typescript import * as path from 'path'; const configDir = path.join(process.cwd(), 'config'); // Always: {project-root}/config ``` **Pros**: Simple, config stays in root **Cons**: Assumes current working directory is project root #### Pattern 3: Environment-Based ```typescript import * as path from 'path'; const configDir = process.env.NODE_ENV === 'production' ? path.join(__dirname, 'config') : path.join(process.cwd(), 'src/config'); ``` **Pros**: Flexible for different environments **Cons**: More complex, harder to maintain ### Best Practice Use Pattern 1 with a proper build configuration: ```typescript // Always use this pattern configDir: path.join(__dirname, 'config') ``` Then ensure your build process copies files to the correct location. ## Profile-Based Loading Configuration files are loaded and merged in priority order (later overrides earlier): 1. **Base**: `application.yml` or `application.json` 2. **Profile**: `application-{profile}.yml` or `application-{profile}.json` 3. **Environment Variables**: Highest priority ### Example With `NODE_ENV=production`: ```yaml # 1. application.yml (loaded first) server: host: localhost port: 3000 database: host: localhost port: 5432 ``` ```yaml # 2. application-production.yml (overrides base) server: host: 0.0.0.0 port: 8080 database: host: prod-db.example.com # 3. Environment variables (highest priority) # DATABASE_PASSWORD=secret123 ``` **Final merged configuration**: ```yaml server: host: 0.0.0.0 # from production profile port: 8080 # from production profile database: host: prod-db.example.com # from production profile port: 5432 # from base password: secret123 # from environment variable ``` ## Advanced Configuration Features ### Environment Variable Placeholders Type Config supports `${VAR_NAME:fallback}` syntax in YAML/JSON files for referencing environment variables with optional fallback values. #### Syntax ```yaml database: host: ${DB_HOST:localhost} # With fallback port: ${DB_PORT:5432} # With fallback username: ${DB_USER:postgres} # With fallback password: ${DB_PASSWORD} # No fallback - undefined if not set api: # Multiple placeholders in one value url: ${API_PROTOCOL:https}://${API_HOST:api.example.com}:${API_PORT:443} message: # Escape with backslash for literal ${TEXT} template: \${USER} logged in ``` #### Resolution Behavior 1. **Environment variable exists**: Uses the environment variable value 2. **Environment variable missing with fallback**: Uses the fallback value 3. **Environment variable missing without fallback**: Field becomes `undefined` #### Precedence Rules Configuration values are resolved in this order (the highest to lowest priority): 1. **Explicit placeholder in profile-specific file** (e.g., `${PROD_VAR:fallback}`) 2. **Explicit placeholder in base file** (e.g., `${BASE_VAR:fallback}`) 3. **Underscore-based ENV variable** (e.g., `DATABASE_HOST` → `database.host`) 4. **Literal value from files** 5. **Default value from @DefaultValue decorator** **Critical**: Explicit placeholders take absolute precedence. If you use `${VAR}` in your config, underscore-based ENV resolution will NOT be applied to that field, even if the placeholder fails to resolve. #### Example with Profiles ```yaml # application.yml database: host: localhost username: ${DB_USER:postgres} password: ${DB_PASSWORD:defaultpass} ``` ```yaml # application-production.yml database: host: prod-db.example.com username: ${PROD_DB_USER:postgres} # Overrides DB_USER placeholder password: ${PROD_DB_PASSWORD} # Overrides DB_PASSWORD placeholder ``` With `NODE_ENV=production`, `PROD_DB_USER=prod_user`, and `PROD_DB_PASSWORD` not set: ```json5 { database: { host: 'prod-db.example.com', // Literal from production profile username: 'prod_user', // From PROD_DB_USER env var password: undefined // PROD_DB_PASSWORD not set, no fallback } } ``` #### Disabling Placeholder Resolution ```typescript const { configManager } = await new ConfigurationBuilder() .withOptions({ enablePlaceholderResolution: false }) .build(); ``` ### Map and Record Configuration Type Config supports binding configuration to `Map<string, T>` or `Record<string, T>` properties for dynamic key-value structures. #### Map-Based Configuration Use `Map<string, T>` for true Map semantics with `.get()`, `.set()`, `.has()` methods: ```typescript import { ConfigurationProperties, ConfigProperty } from '@snow-tzu/type-config'; class DatabaseConnection { host: string; port: number; username: string; password: string; } @ConfigurationProperties('databases') class DatabasesConfig { @ConfigProperty('connections') connections: Map<string, DatabaseConnection>; } ``` ```yaml # config/application.yml databases: connections: primary: host: localhost port: 5432 username: postgres password: secret analytics: host: analytics-db.example.com port: 5432 username: analytics_user password: analytics_pass ``` **Usage**: ```typescript const dbConfig = container.get(DatabasesConfig); const primary = dbConfig.connections.get('primary'); console.log(`Primary DB: ${primary.host}:${primary.port}`); ``` #### Record-Based Configuration Use `Record<string, T>` as an alternative to Map with plain object syntax: ```typescript import { ConfigurationProperties, ConfigProperty, Required } from '@snow-tzu/type-config'; class DatabaseConnection { host: string; port: number; username: string; password: string; } @ConfigurationProperties('databases') class DatabasesRecordConfig { @ConfigProperty('connections') @Required() connections: Record<string, DatabaseConnection>; } ``` **Usage**: ```typescript const dbConfig = container.get(DatabasesRecordConfig); const primary = dbConfig.connections['primary']; // or const primary = dbConfig.connections.primary; console.log(`Primary DB: ${primary.host}:${primary.port}`); ``` #### Map vs Record | Feature | Map<string, T> | Record<string, T> | |--------------|-------------------------------------|---------------------------------| | **Type** | True Map instance | Plain JavaScript object | | **Syntax** | `map.get('key')` | `record['key']` or `record.key` | | **Use Case** | Need Map methods (.get, .set, .has) | Prefer plain object syntax | #### Combining with Placeholders ```yaml databases: connections: primary: host: ${PRIMARY_DB_HOST:localhost} port: ${PRIMARY_DB_PORT:5432} username: ${PRIMARY_DB_USER:postgres} password: ${PRIMARY_DB_PASSWORD} analytics: host: ${ANALYTICS_DB_HOST:localhost} port: 5432 username: analytics_user password: ${ANALYTICS_DB_PASSWORD} ``` This combines Map/Record binding with environment variable placeholders for maximum flexibility! #### Accessing Map/Record Values via ConfigManager ```typescript // Deep path access (works with both Map and Record) const primaryHost = configManager.get<string>('databases.connections.primary.host'); // Get entire entry const primaryConfig = configManager.get('databases.connections.primary'); // Get entire map/record as object const allConnections = configManager.get('databases.connections'); // With default value const cacheHost = configManager.get('databases.connections.cache.host', 'localhost'); ``` **Note**: When using `configManager.get()` with Map-based configuration, the Map is returned as a plain object for easier access. ## Troubleshooting ### Error: "ENOENT: no such file or directory" **Symptom**: ``` Error: ENOENT: no such file or directory, open '/path/to/dist/config/application.yml' ``` **Causes & Solutions**: 1. **Config files not copied to dist/** - ✅ Add a copy script to package.json - ✅ Configure a build tool to copy assets (nest-cli.json for NestJS) - ✅ Verify files exist: `ls dist/src/config/` or `ls dist/config/` 2. **Wrong configDir path** - ✅ Use `path.join(__dirname, 'config')` instead of relative paths - ✅ Check where __dirname points in your environment - ✅ Add debug logging: `console.log('Config dir:', configDir)` 3. **Build process deletes files** - ✅ Ensure copy happens AFTER build - ✅ For NestJS: use nest-cli.json assets configuration - ✅ For others: `"build": "tsc && npm run copy:config"` ### Error: "Required configuration property 'xxx' is missing" **Symptom**: ``` Error: Required configuration property 'database.host' is missing ``` **Causes & Solutions**: 1. **Configuration file is empty or not parsed** - ✅ Verify YAML/JSON syntax is valid - ✅ Check file has content: `cat dist/src/config/application.yml` - ✅ Ensure file extension is correct (.yml, .yaml, or .json) 2. **Wrong profile loaded** - ✅ Check NODE_ENV value: `echo $NODE_ENV` - ✅ Verify profile-specific file exists - ✅ Add logging to see which files are loaded 3. **Property path mismatch** - ✅ Ensure @ConfigurationProperties prefix matches YAML structure - ✅ Check @ConfigProperty names match YAML keys - ✅ Example: ```typescript @ConfigurationProperties('database') // Must match YAML class DatabaseConfig { @ConfigProperty('host') // Must match: database.host host: string; } ``` ### Configuration not updating in development **Symptom**: Changes to config files don't take effect **Causes & Solutions**: 1. **Watch mode doesn't copy files** - ✅ Run copy script before starting: `npm run copy:config && npm run dev` - ✅ Or use hot reload: `enableHotReload: true` 2. **Cached configuration** - ✅ Restart the application - ✅ Clear dist folder: `rm -rf dist && npm run build` 3. **Wrong file being read** - ✅ Add debug logging to see which file is loaded - ✅ Check if a profile-specific file overrides your changes ### Files copied but still not found **Symptom**: Files exist in dist/ but the application can't find them **Causes & Solutions**: 1. **Working directory mismatch** - ✅ Check current working directory: `console.log(process.cwd())` - ✅ Use absolute paths: `path.join(__dirname, 'config')` - ✅ Don't rely on relative paths like `./config` 2. **Incorrect path in configDir** - ✅ Verify: `console.log('Looking for config at:', configDir)` - ✅ Check file exists: `fs.existsSync(path.join(configDir, 'application.yml'))` 3. **Symlink or Docker volume issues** - ✅ Use absolute paths - ✅ Verify files are actually copied (not just linked) - ✅ Check Docker volume mounts ### Placeholder is not resolving **Symptom**: `${VAR:fallback}` appears literally in configuration or resolves incorrectly **Causes & Solutions**: 1. **Placeholder resolution disabled** - ✅ Check if `enablePlaceholderResolution: false` is set - ✅ Default is `true`, so ensure you haven't explicitly disabled it 2. **Malformed placeholder syntax** - ✅ Correct: `${VAR_NAME:fallback}` or `${VAR_NAME}` - ✅ Incorrect: `$VAR_NAME`, `${VAR_NAME:}` (empty fallback is valid though) - ✅ Ensure no spaces: `${ VAR }` won't work 3. **Environment variable name mismatch** - ✅ Check exact variable name: `echo $VAR_NAME` - ✅ Variable names are case-sensitive - ✅ Add debug: `console.log('ENV:', process.env.VAR_NAME)` 4. **Escaped placeholder** - ✅ `\${TEXT}` produces literal `${TEXT}` - this is intentional - ✅ Remove backslash if you want resolution ### Map/Record binding is not working **Symptom**: Map property is undefined or not a Map instance **Causes & Solutions**: 1. **TypeScript metadata not emitted** - ✅ Ensure `"emitDecoratorMetadata": true` in tsconfig.json - ✅ Ensure `"experimentalDecorators": true` in tsconfig.json - ✅ Import `reflect-metadata` at application entry point 2. **Configuration structure mismatch** - ✅ YAML must have object structure for Map/Record binding - ✅ Example: ```yaml connections: key1: { host: localhost } key2: { host: remote } ``` - ✅ Not: `connections: "string"` or `connections: [array]` 3. **Wrong property type annotation** - ✅ Use `Map<string, T>` not `Map<any, any>` - ✅ Use `Record<string, T>` not just `object` - ✅ Ensure value type `T` is properly defined 4. **Map not being created** - ✅ Verify the property is typed as `Map<string, T>` (not just `any` or `object`) - ✅ Check that configuration data exists at the specified path - ✅ Add debug logging: `console.log(configManager.get('your.path'))` ### Precedence is not working as expected **Symptom**: Wrong value is used when multiple sources provide the same property **Causes & Solutions**: 1. **Explicit placeholder vs underscore-based ENV** - ✅ Explicit placeholder `${VAR}` ALWAYS takes precedence - ✅ Even if the placeholder fails, underscore-based ENV won't be used - ✅ Example: `password: ${DB_PASS}` with `DATABASE_PASSWORD=secret` - Result: `undefined` (not "secret") if DB_PASS not set 2. **Profile-specific not overriding base** - ✅ Verify profile is set: `console.log(configManager.getProfile())` - ✅ Check profile file exists: `application-{profile}.yml` - ✅ Ensure the profile file is loaded after a base file 3. **Environment variable not overriding file** - ✅ Check ENV var is actually set: `echo $VAR_NAME` - ✅ Verify underscore-based naming: `DATABASE_HOST` → `database.host` - ✅ For kebab-case: `databases.connections.my-db.host` → `DATABASES_CONNECTIONS_MY_DB_HOST` ## Quick Checklist Before deploying or running your application: - [ ] Configuration files exist in a source (`src/config/` or `config/`) - [ ] Build script copies config files to dist - [ ] Verify files in dist: `ls dist/src/config/` or `ls dist/config/` - [ ] configDir path uses `path.join(__dirname, 'config')` - [ ] YAML/JSON syntax is valid - [ ] Profile-specific files exist for your environment - [ ] @ConfigurationProperties prefix matches YAML structure - [ ] Required properties have values in config files - [ ] Environment variables are set (if needed) ## Additional Resources - [Core Package README](./README.md) - [NestJS Package README](../nestjs/README.md) - [Express Package README](../express/README.md) - [Fastify Package README](../fastify/README.md) - [Examples Directory](../../examples/) ## Need Help? If you're still having issues: 1. Enable debug logging in your application 2. Check the [GitHub Issues](https://github.com/ganesanarun/type-config/issues) 3. Review the [examples directory](../../examples/) for working configurations 4. Create a minimal reproduction case