Skip to content

Type-safe configuration management for Node.js/TypeScript with environment variable and config file support

License

Notifications You must be signed in to change notification settings

lanemc/type-safe-config-loader

Repository files navigation

Type-Safe Config Loader

npm version TypeScript License: MIT npm downloads

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.PORT is 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, .env files, 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 zod

2. 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=true

That'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 zod

Requirements:

  • 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; // boolean

Environment-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.yaml

Multiple 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):

  1. Environment variables
  2. Config files (later files override earlier ones)
  3. 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 schema
  • options?: 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 schema
  • options?: 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: false

Configuration 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: 3600

JSON 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=3600

Environment 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.yaml

Migration 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 number

2. 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=localhost

4. 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 .gitignore

5. 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 lint

Running Tests

# Run all tests
npm test

# Watch mode
npm run test:watch

# Coverage report
npm run test:coverage

License

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 zod

For questions, issues, or feature requests, please visit our GitHub repository.

About

Type-safe configuration management for Node.js/TypeScript with environment variable and config file support

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors