type-safe-config-loader
v1.0.0
Published
Type-safe configuration management for Node.js/TypeScript with environment variable and config file support
Maintainers
Readme
Type-Safe Config Loader
A modern, developer-friendly configuration management library for Node.js and TypeScript. Get type-safe, validated configuration from environment variables and files with zero runtime surprises.
Why Type-Safe Config Loader?
Stop debugging configuration issues in production. Traditional config management in Node.js is error-prone:
- ❌
process.env.PORTis always a string (or undefined) - ❌ Missing required config only discovered at runtime
- ❌ Type mismatches cause silent failures
- ❌ Secrets accidentally logged in error messages
- ❌ No single source of truth for required configuration
Type-Safe Config Loader solves these problems:
- ✅ Type-Safe: Full TypeScript support with auto-completion
- ✅ Fail-Fast: Validate configuration at startup, not runtime
- ✅ Multi-Source: Environment variables,
.envfiles, YAML, JSON - ✅ Secure: Automatic masking of sensitive values
- ✅ Zero Config: Works out of the box, configurable when needed
- ✅ Developer-Friendly: Clear error messages and excellent DX
// Before: Unsafe, untyped, error-prone
const port = parseInt(process.env.PORT || '3000'); // 😱
const dbUrl = process.env.DATABASE_URL; // string | undefined 😱
// After: Type-safe, validated, bulletproof
const config = loadConfig(schema);
config.port; // number ✅
config.database.url; // string ✅Quick Start
1. Install
npm install type-safe-config-loader zod2. Define Your Schema
// config.ts
import { z } from 'zod';
import { loadConfig, defineConfig } from 'type-safe-config-loader';
const configSchema = defineConfig({
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
DEBUG: z.coerce.boolean().default(false),
});
export const config = loadConfig(configSchema, {
dotenv: true,
sensitive: ['API_KEY', 'DATABASE_URL']
});3. Use Everywhere
// server.ts
import { config } from './config';
app.listen(config.PORT, () => {
console.log(`Server running on port ${config.PORT}`);
// config.PORT is typed as number ✅
// config.API_KEY is typed as string ✅
// config.MISSING_PROP // TypeScript error ✅
});4. Environment Setup
# .env (development)
DATABASE_URL=postgres://localhost:5432/myapp
API_KEY=dev-key-123
DEBUG=trueThat's it! Your configuration is now type-safe, validated, and secure.
Installation
# npm
npm install type-safe-config-loader zod
# yarn
yarn add type-safe-config-loader zod
# pnpm
pnpm add type-safe-config-loader zodRequirements:
- Node.js 16+
- TypeScript 4.5+ (for optimal type inference)
Usage Examples
Basic Configuration
import { z } from 'zod';
import { loadConfig, defineConfig } from 'type-safe-config-loader';
// Define what your app needs
const schema = defineConfig({
PORT: z.coerce.number().default(3000),
HOST: z.string().default('localhost'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
// Load and validate
const config = loadConfig(schema);
// Use with full type safety
console.log(`Server: http://${config.HOST}:${config.PORT}`);Advanced Schema with Nested Objects
const advancedSchema = defineConfig({
// Server configuration
server: z.object({
port: z.coerce.number().int().positive().default(3000),
host: z.string().default('localhost'),
cors: z.object({
enabled: z.coerce.boolean().default(true),
origins: z.string().transform(s => s.split(',')).default('*'),
}),
}),
// Database configuration
database: z.object({
url: z.string().url(),
pool: z.object({
min: z.coerce.number().int().min(0).default(2),
max: z.coerce.number().int().min(1).default(10),
}),
ssl: z.coerce.boolean().default(false),
}),
// Feature flags
features: z.object({
analytics: z.coerce.boolean().default(false),
cache: z.object({
enabled: z.coerce.boolean().default(true),
ttl: z.coerce.number().int().positive().default(3600),
}),
}).default({}),
// External services
services: z.object({
redis: z.object({
url: z.string().url().optional(),
keyPrefix: z.string().default('app:'),
}).optional(),
}).default({}),
});
const config = loadConfig(advancedSchema, {
file: 'config.yaml',
sensitive: ['database.url', 'services.redis.url']
});
// Fully typed access
config.server.port; // number
config.database.pool.max; // number
config.features.cache.enabled; // booleanEnvironment-Specific Configuration
// Supports dynamic file loading based on NODE_ENV
const config = loadConfig(schema, {
file: 'config.${NODE_ENV}.yaml', // config.development.yaml, config.production.yaml
dotenv: true,
strict: true
});File Structure:
config/
├── config.development.yaml
├── config.production.yaml
└── config.test.yamlMultiple Configuration Sources
const config = loadConfig(schema, {
file: ['config/base.yaml', 'config/overrides.yaml'],
dotenv: '.env.local',
envPrefix: 'MYAPP_' // Only load env vars starting with MYAPP_
});Loading Priority (highest to lowest):
- Environment variables
- Config files (later files override earlier ones)
- Schema defaults
Async Configuration Loading
import { loadConfigAsync } from 'type-safe-config-loader';
async function initializeApp() {
const config = await loadConfigAsync(schema, {
file: 'config.yaml'
});
// Start your app with validated config
startServer(config);
}Custom Validation and Transforms
const schema = defineConfig({
// Custom validation
email: z.string().email(),
// Transform values
tags: z.string().transform(s => s.split(',').map(t => t.trim())),
// Complex validation
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[0-9]/, 'Password must contain number'),
// Conditional validation
httpsPort: z.coerce.number().int().positive().optional()
.refine((port, ctx) => {
if (ctx.parent.NODE_ENV === 'production' && !port) {
throw new Error('HTTPS port required in production');
}
return true;
}),
});API Reference
loadConfig(schema, options?)
Synchronously loads and validates configuration.
Parameters:
schema: ZodSchema | ConfigSchemaDefinition- Configuration schemaoptions?: ConfigOptions- Loading options
Returns: Validated configuration object with full TypeScript types
Example:
const config = loadConfig(mySchema, {
file: 'config.yaml',
dotenv: true
});loadConfigAsync(schema, options?)
Asynchronously loads and validates configuration.
Parameters:
schema: ZodSchema | ConfigSchemaDefinition- Configuration schemaoptions?: ConfigOptions- Loading options
Returns: Promise<ValidatedConfig>
Example:
const config = await loadConfigAsync(mySchema, {
file: 'config.yaml'
});defineConfig<T>(shape: T)
Helper function to create a Zod object schema with better TypeScript inference.
Parameters:
shape: ZodRawShape- Schema shape definition
Returns: ZodObject<T>
Example:
const schema = defineConfig({
PORT: z.coerce.number().default(3000),
DEBUG: z.coerce.boolean().default(false)
});printConfigSchema(schema)
Generates human-readable documentation of your configuration schema.
Parameters:
schema: ZodSchema | ConfigSchemaDefinition- Configuration schema
Returns: string - Formatted schema documentation
Example:
console.log(printConfigSchema(mySchema));
// Output:
// Expected Configuration:
// - PORT (number) [Optional] Default: 3000
// - DATABASE_URL (string) [Required]
// - DEBUG (boolean) [Optional] Default: falseConfiguration Options
ConfigOptions
interface ConfigOptions {
/** Config file path(s) to load */
file?: string | string[];
/** Load .env file (true, false, or custom path) */
dotenv?: boolean | string;
/** Only load env vars with this prefix */
envPrefix?: string;
/** Require all files to exist */
strict?: boolean;
/** Exit process on validation error */
exitOnError?: boolean;
/** Custom error handler */
onError?: (errors: ConfigError[]) => void;
/** Keys to mask in logs/errors */
sensitive?: string[];
}Default Behavior
const defaultOptions: ConfigOptions = {
dotenv: false,
strict: false,
exitOnError: true,
sensitive: []
};File Format Support
YAML Configuration
# config.yaml
server:
port: 3000
host: localhost
database:
url: postgres://localhost:5432/myapp
pool:
min: 2
max: 10
features:
analytics: false
cache:
enabled: true
ttl: 3600JSON Configuration
{
"server": {
"port": 3000,
"host": "localhost"
},
"database": {
"url": "postgres://localhost:5432/myapp",
"pool": {
"min": 2,
"max": 10
}
},
"features": {
"analytics": false,
"cache": {
"enabled": true,
"ttl": 3600
}
}
}Environment Variables
# .env
SERVER_PORT=3000
SERVER_HOST=localhost
DATABASE_URL=postgres://localhost:5432/myapp
DATABASE_POOL_MIN=2
DATABASE_POOL_MAX=10
FEATURES_ANALYTICS=false
FEATURES_CACHE_ENABLED=true
FEATURES_CACHE_TTL=3600Environment Variable Mapping:
- Nested objects:
PARENT_CHILD_FIELD - Arrays:
TAGS=tag1,tag2,tag3(with transform) - Booleans:
true,false,1,0 - Numbers: Auto-parsed with
z.coerce.number()
Error Handling
Validation Errors
When configuration is invalid, you get clear, actionable error messages:
// Missing required field
Config validation failed with 1 error:
- API_KEY: Required field is missing
// Type mismatch
Config validation failed with 1 error:
- PORT: Expected number, received "not-a-number"
// Multiple errors
Config validation failed with 3 errors:
- PORT: Expected number, received "abc"
- DATABASE_URL: Invalid URL format
- LOG_LEVEL: Invalid enum value. Expected 'debug' | 'info' | 'warn' | 'error', received 'verbose'Custom Error Handling
const config = loadConfig(schema, {
exitOnError: false, // Don't exit process
onError: (errors) => {
// Custom logging
logger.error('Configuration validation failed:', errors);
// Send to monitoring service
monitoring.track('config_validation_failed', { errors });
}
});Sensitive Data Protection
Sensitive values are automatically masked in error messages and logs:
const config = loadConfig(schema, {
sensitive: ['API_KEY', 'DATABASE_URL', 'JWT_SECRET']
});
// Error message shows:
// - API_KEY: Invalid format (value: ***)
// Instead of exposing the actual value
console.log(config.toString());
// Output:
// {
// "PORT": 3000,
// "API_KEY": "***",
// "DATABASE_URL": "***"
// }Testing
Testing Configuration
// test-config.ts
import { loadConfig } from 'type-safe-config-loader';
import { testSchema } from '../test-helpers';
describe('Configuration', () => {
it('should load valid config', () => {
process.env.PORT = '3000';
process.env.DATABASE_URL = 'postgres://localhost:5432/test';
const config = loadConfig(testSchema);
expect(config.PORT).toBe(3000);
expect(config.DATABASE_URL).toBe('postgres://localhost:5432/test');
});
it('should fail with invalid config', () => {
process.env.PORT = 'invalid';
expect(() => loadConfig(testSchema, { exitOnError: false }))
.toThrow('Config validation failed');
});
});Test Helpers
// test-helpers.ts
import { createTestConfig } from 'type-safe-config-loader/testing';
export const testConfig = createTestConfig(mySchema, {
PORT: 3000,
DATABASE_URL: 'postgres://localhost:5432/test',
API_KEY: 'test-api-key'
});
// Use in tests
it('should handle user creation', () => {
const result = createUser(testConfig);
expect(result).toBeDefined();
});Performance
Type-Safe Config Loader is designed for minimal startup overhead:
- Validation: ~1-2ms for typical schemas (< 50 fields)
- File Loading: Minimal I/O with efficient parsing
- Memory: < 1MB additional memory usage
- Bundle Size: ~50KB (including Zod dependency)
Performance is measured at application startup only - no runtime overhead.
Integrations
Express.js
import express from 'express';
import { config } from './config';
const app = express();
app.listen(config.server.port, () => {
console.log(`Server running on http://${config.server.host}:${config.server.port}`);
});Fastify
import Fastify from 'fastify';
import { config } from './config';
const fastify = Fastify({
logger: config.LOG_LEVEL !== 'debug'
});
await fastify.listen({
port: config.server.port,
host: config.server.host
});NestJS
// config.service.ts
import { Injectable } from '@nestjs/common';
import { config } from './config';
@Injectable()
export class ConfigService {
get database() {
return config.database;
}
get server() {
return config.server;
}
}Docker
# Dockerfile
ENV NODE_ENV=production
ENV PORT=3000
ENV DATABASE_URL=postgres://db:5432/myapp
COPY config.production.yaml ./config.yaml# docker-compose.yml
version: '3.8'
services:
app:
build: .
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://db:5432/myapp
- API_KEY=${API_KEY}
volumes:
- ./config.production.yaml:/app/config.yamlMigration Guide
From dotenv
// Before
require('dotenv').config();
const port = parseInt(process.env.PORT || '3000');
const dbUrl = process.env.DATABASE_URL || '';
// After
import { loadConfig, defineConfig } from 'type-safe-config-loader';
import { z } from 'zod';
const schema = defineConfig({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
});
const config = loadConfig(schema, { dotenv: true });From node-config
// Before
import config from 'config';
const port = config.get('server.port');
// After
import { loadConfig, defineConfig } from 'type-safe-config-loader';
const schema = defineConfig({
server: z.object({
port: z.coerce.number().default(3000)
})
});
const config = loadConfig(schema, { file: 'config.yaml' });
const port = config.server.port; // Fully typed!From convict
// Before
const convict = require('convict');
const config = convict({
port: {
doc: 'The port to bind.',
format: 'port',
default: 3000,
env: 'PORT'
}
});
// After
const schema = defineConfig({
port: z.coerce.number().int().min(1).max(65535).default(3000)
});
const config = loadConfig(schema, { dotenv: true });Troubleshooting
Common Issues
1. TypeScript errors with inferred types
// Problem: Type inference not working
const config = loadConfig(schema);
// config is 'any'
// Solution: Ensure proper schema typing
const schema = defineConfig({
PORT: z.coerce.number().default(3000)
});
// Now config.PORT is properly typed as number2. Environment variables not loading
// Problem: .env file not found
const config = loadConfig(schema, { dotenv: true });
// Solution: Check .env file location
const config = loadConfig(schema, {
dotenv: '.env.local' // Specify custom path
});3. Nested object validation failing
// Problem: Flat env vars for nested config
// DATABASE_HOST=localhost doesn't map to config.database.host
// Solution: Use nested schema structure
const schema = defineConfig({
database: z.object({
host: z.string().default('localhost')
})
});
// Set env var as: DATABASE_HOST=localhost4. File loading errors
// Problem: Config file not found
const config = loadConfig(schema, {
file: 'missing-config.yaml',
strict: true // This will throw error if file missing
});
// Solution: Make file optional or check existence
const config = loadConfig(schema, {
file: 'config.yaml',
strict: false // Allow missing files
});Debug Mode
Enable debug logging to troubleshoot configuration loading:
const config = loadConfig(schema, {
file: 'config.yaml',
dotenv: true,
onError: (errors) => {
console.log('Config validation errors:', errors);
}
});
// Use schema printer for documentation
console.log(printConfigSchema(schema));Best Practices
1. Schema Organization
// ✅ Good: Organize by domain
const schema = defineConfig({
server: z.object({
port: z.coerce.number().default(3000),
host: z.string().default('localhost'),
}),
database: z.object({
url: z.string().url(),
pool: z.object({
min: z.coerce.number().default(2),
max: z.coerce.number().default(10),
}),
}),
cache: z.object({
enabled: z.coerce.boolean().default(true),
ttl: z.coerce.number().default(3600),
}),
});
// ❌ Avoid: Flat structure for complex apps
const schema = defineConfig({
SERVER_PORT: z.coerce.number().default(3000),
SERVER_HOST: z.string().default('localhost'),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MIN: z.coerce.number().default(2),
// ... gets unwieldy quickly
});2. Environment-Specific Defaults
const schema = defineConfig({
logLevel: z.enum(['debug', 'info', 'warn', 'error'])
.default(process.env.NODE_ENV === 'development' ? 'debug' : 'info'),
database: z.object({
ssl: z.coerce.boolean()
.default(process.env.NODE_ENV === 'production'),
}),
});3. Validation Rules
// ✅ Good: Specific validation
const schema = defineConfig({
port: z.coerce.number().int().min(1).max(65535),
email: z.string().email(),
url: z.string().url(),
// Custom validation
apiKey: z.string().min(32, 'API key must be at least 32 characters'),
});
// ❌ Avoid: Generic validation
const schema = defineConfig({
port: z.coerce.number(), // Could be negative or too large
email: z.string(), // Could be invalid email
url: z.string(), // Could be invalid URL
});4. Sensitive Data Handling
// ✅ Good: Mark all sensitive fields
const config = loadConfig(schema, {
sensitive: [
'database.password',
'apiKey',
'jwtSecret',
'oauth.clientSecret'
]
});
// ✅ Good: Use specific env vars for secrets
// In production, set via secure env injection
// In development, use .env with .gitignore5. Error Handling
// ✅ Good: Fail fast in production
const config = loadConfig(schema, {
exitOnError: process.env.NODE_ENV === 'production',
onError: (errors) => {
if (process.env.NODE_ENV === 'development') {
console.log('\n📋 Expected configuration:');
console.log(printConfigSchema(schema));
}
}
});Contributing
We welcome contributions! Please see our Contributing Guide for details.
Development Setup
# Clone repository
git clone https://github.com/lanemc/type-safe-config-loader.git
cd type-safe-config-loader
# Install dependencies
npm install
# Run tests
npm test
# Build
npm run build
# Type check
npm run typecheck
# Lint
npm run lintRunning Tests
# Run all tests
npm test
# Watch mode
npm run test:watch
# Coverage report
npm run test:coverageLicense
MIT © Lane McGregor
Changelog
See CHANGELOG.md for version history.
Ready to eliminate configuration bugs? Install Type-Safe Config Loader today and never worry about runtime config errors again.
npm install type-safe-config-loader zodFor questions, issues, or feature requests, please visit our GitHub repository.
