@betternest/config
v2.0.3
Published
Type-safe, validated configuration for NestJS with class-validator and enhanced error messages
Maintainers
Readme
@betternest/config
Type-safe, validated configuration for NestJS applications
@betternest/config provides a clean, decorator-based approach to configuration management in NestJS. It combines TypeScript type safety, automatic validation with class-validator, dependency injection support, and detailed error reporting.
Features
- ✅ Type-Safe - Full TypeScript support with type inference
- ✅ Validated - Automatic validation using class-validator decorators
- ✅ Dependency Injection - Constructor injection for async values
- ✅ Enhanced Errors - Detailed validation messages with constraint details (enum values, min/max limits, etc.)
- ✅ Nested Validation - Recursive validation for complex config structures
- ✅ Flexible - Support for factory providers and async initialization
- ✅ Fail-Fast - Application refuses to start with invalid configuration
Installation
npm install @betternest/config class-validator class-transformer
# or
yarn add @betternest/config class-validator class-transformer
# or
pnpm add @betternest/config class-validator class-transformerQuick Start
1. Define Your Configuration Model
Create an abstract class with validation decorators:
// app.config.ts
import { IsString, IsNumber, IsNotEmpty, Min, Max } from 'class-validator';
export abstract class AppConfig {
@IsString()
@IsNotEmpty()
apiUrl: string;
@IsNumber()
@Min(1000)
@Max(9999)
port: number;
}2. Create Configuration Values
Use the @ConfigFor decorator to link values to the model:
// app.config.values.ts
import { ConfigFor, ConfigValues, Env } from '@betternest/config';
import { AppConfig } from './app.config';
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(private env: Env) {}
apiUrl = this.env.API_URL || 'http://localhost:3000';
port = Number(this.env.PORT) || 3000;
}3. Register in Your Module
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@betternest/config';
import { AppConfigValues } from './app.config.values';
@Module({
imports: [
ConfigModule.register({
values: { AppConfigValues },
}),
],
})
export class AppModule {}4. Inject and Use
// app.service.ts
import { Injectable } from '@nestjs/common';
import { AppConfig } from './app.config';
@Injectable()
export class AppService {
constructor(private readonly config: AppConfig) {}
getServerInfo() {
// Full type-safety and auto-completion!
return {
url: this.config.apiUrl,
port: this.config.port,
};
}
}Core Concepts
Configuration Model vs Configuration Values
@betternest/config uses a separation of concerns:
- Config Model (
abstract class) - Defines the structure and validation rules - Config Values (
@ConfigForclass) - Provides the actual values
This separation allows:
- Type-safe configuration without implementation details
- Dependency injection support in values classes
- Clear separation between "what" (model) and "how" (values)
// Model: WHAT the config looks like + validation
export abstract class DatabaseConfig {
@IsString() @IsNotEmpty()
host: string;
@IsNumber() @Min(1) @Max(65535)
port: number;
}
// Values: HOW to get the config
@ConfigFor(DatabaseConfig)
export class DatabaseConfigValues implements ConfigValues<DatabaseConfig> {
constructor(private env: Env) {}
host = this.env.DB_HOST || 'localhost';
port = Number(this.env.DB_PORT) || 5432;
}The @ConfigFor Decorator
The @ConfigFor decorator links a values class to its validation model:
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
// Values here will be validated against AppConfig
}Important: Every ConfigValues class must be decorated with @ConfigFor, or you'll get a helpful error message at startup.
API Reference
ConfigModule.register(options)
Registers the configuration module with specified options.
interface ConfigModuleOptions {
// Config values classes to register
values: Record<string, Type<ConfigValues<any>>>;
// Load .env file automatically (default: true)
loadDotenv?: boolean;
// Options to pass to dotenv when loading .env file
dotenvOptions?: DotenvConfigOptions;
// Register as global module (default: true)
global?: boolean;
// Additional providers for dependency injection
providers?: Provider[];
// Additional imports
imports?: Array<Type<any> | DynamicModule>;
// Additional exports
exports?: Array<string | symbol | Provider | Type<any>>;
// Additional controllers
controllers?: Type<any>[];
// Custom module class (advanced)
module?: Type<any>;
// Exit the process on validation error (default: true)
exitOnError?: boolean;
// Enable debug logging (default: false)
debug?: boolean;
}Example:
ConfigModule.register({
values: {
AppConfigValues,
DatabaseConfigValues,
},
// loadDotenv: true by default (set to false to disable .env loading)
// global: true by default
})ConfigModule.forAsync()
Creates a factory configuration object for NestJS async module registration. This utility simplifies the common pattern of injecting a config class for modules that support async registration (e.g., forRootAsync, forFeatureAsync).
// Pattern 1: Extract a specific property from config
ConfigModule.forAsync(DatabaseConfig, 'mongooseOptions')
// Returns: { inject: [DatabaseConfig], useFactory: (config) => config.mongooseOptions }
// Pattern 2: Inject entire config object
ConfigModule.forAsync(DatabaseConfig)
// Returns: { inject: [DatabaseConfig], useFactory: (config) => config }Example:
import { ConfigModule } from '@betternest/config';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
// ✅ Clean and concise
MongooseModule.forRootAsync(
ConfigModule.forAsync(DatabaseConfig, 'mongooseOptions')
),
// ❌ Verbose alternative (same result)
MongooseModule.forRootAsync({
inject: [DatabaseConfig],
useFactory: (config: DatabaseConfig) => config.mongooseOptions,
})
]
})Type Helpers
ConfigValues<Schema>
Type-safe interface for config values classes with automatic type transformations:
import { ConfigValues } from '@betternest/config';
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
// All properties from AppConfig must be present
apiUrl = '...';
port = 3000;
}The ConfigValues<T> type automatically transforms your schema:
- Tuples become arrays
- Literal strings become
string - Objects are deeply transformed
- Preserves all other types
StrictConfigValues<Schema>
For cases where you want exact type matching without transformations:
import { StrictConfigValues } from '@betternest/config';
@ConfigFor(AppConfig)
export class AppConfigValues implements StrictConfigValues<AppConfig> {
// Must match AppConfig types exactly
}Advanced Usage
Dependency Injection in ConfigValues
ConfigValues classes support constructor injection. You can combine Env injection with custom providers:
import { ConfigFor, ConfigValues, Env } from '@betternest/config';
import { Inject } from '@nestjs/common';
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(
private env: Env,
@Inject('IP_ADDRESS') private readonly ipAddress: string,
) {}
apiUrl = `http://${this.ipAddress}:3000`;
port = Number(this.env.PORT) || 3000;
}Async Factory Providers
You can combine Env injection with async factory providers for dynamic configuration:
ConfigModule.register({
values: { AppConfigValues },
providers: [
{
provide: 'IP_ADDRESS',
useFactory: async () => {
const response = await fetch('https://api.ipify.org?format=text');
return response.text();
},
},
],
})Then use both in your ConfigValues class:
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(
private env: Env,
@Inject('IP_ADDRESS') private readonly ipAddress: string,
) {}
apiUrl = `http://${this.ipAddress}:${this.env.PORT || '3000'}`;
environment = this.env.NODE_ENV || 'development';
}Multiple Configuration Classes
Register multiple config classes in one module:
// app.config.ts
export abstract class AppConfig {
@IsString() apiUrl: string;
@IsNumber() port: number;
}
// database.config.ts
export abstract class DatabaseConfig {
@IsString() host: string;
@IsNumber() port: number;
}
// app.module.ts
ConfigModule.register({
values: {
AppConfigValues,
DatabaseConfigValues,
},
})Each config class is automatically exported and can be injected:
@Injectable()
export class AppService {
constructor(
private readonly appConfig: AppConfig,
private readonly dbConfig: DatabaseConfig,
) {}
}Nested Object Validation
Validate complex nested structures:
import { ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
class OctetConfig {
@IsNumber() @Min(0) @Max(255)
value: number;
@IsNumber() @Min(1) @Max(4)
position: number;
}
class IpAddressInfo {
@IsEnum(['IPv4', 'IPv6'])
version: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OctetConfig)
octets: OctetConfig[];
}
export abstract class NetworkConfig {
@ValidateNested()
@Type(() => IpAddressInfo)
ipAddressInfo: IpAddressInfo;
}Errors are reported with full paths:
❌ Configuration validation failed for NetworkConfigValues:
• ipAddressInfo.version: "IPv5"
- version must be one of the following values: "IPv4", "IPv6"
• ipAddressInfo.octets.0.position: 10
- position must not be greater than 4 (max: 4)Enhanced Error Messages
Standard Validation Errors
export abstract class AppConfig {
@IsString() @IsNotEmpty()
apiKey: string;
@IsNumber() @Min(1000) @Max(9999)
port: number;
}Invalid configuration produces detailed errors:
❌ Configuration validation failed for AppConfigValues:
• apiKey: ""
- apiKey should not be empty
• port: 99999
- port must not be greater than 9999 (max: 9999)Enum Validation with Values
@IsEnum(['development', 'staging', 'production'])
environment: string;Shows allowed values:
• environment: "dev"
- environment must be one of the following values: "development", "staging", "production"Min/Max with Limits
@IsNumber() @Min(1) @Max(100)
maxConnections: number;Shows the constraint:
• maxConnections: 500
- maxConnections must not be greater than 100 (max: 100)Length Validation
@IsString() @MinLength(8) @MaxLength(255)
password: string;Shows constraints:
• password: "short"
- password must be longer than or equal to 8 characters (min: 8, max: 255)Common Validators
import {
IsString,
IsNumber,
IsBoolean,
IsEmail,
IsUrl,
IsEnum,
IsNotEmpty,
Min,
Max,
MinLength,
MaxLength,
Matches,
IsInt,
IsPositive,
} from 'class-validator';
export abstract class ExampleConfig {
// Strings
@IsString() @IsNotEmpty()
name: string;
@IsEmail()
email: string;
@IsUrl()
apiUrl: string;
@Matches(/^[a-z0-9-]+$/)
slug: string;
@MinLength(8) @MaxLength(255)
password: string;
// Numbers
@IsNumber() @Min(1) @Max(100)
maxConnections: number;
@IsInt()
port: number;
@IsPositive()
timeout: number;
// Booleans
@IsBoolean()
debugMode: boolean;
// Enums
@IsEnum(['development', 'production'])
environment: string;
}Testing
Mocking Configuration
You can mock either the config itself or the Env class:
import { Test } from '@nestjs/testing';
import { AppConfig } from './app.config';
import { Env } from '@betternest/config';
describe('AppService', () => {
it('should use mocked config', async () => {
const mockConfig: AppConfig = {
apiUrl: 'http://test.api.com',
port: 3000,
};
const module = await Test.createTestingModule({
providers: [
AppService,
{
provide: AppConfig,
useValue: mockConfig,
},
],
}).compile();
const service = module.get(AppService);
expect(service.getServerInfo().url).toBe('http://test.api.com');
});
it('should use mocked env', async () => {
const mockEnv: Partial<Env> = {
API_URL: 'http://test.api.com',
PORT: '3000',
};
const module = await Test.createTestingModule({
providers: [
AppService,
AppConfigValues,
{
provide: Env,
useValue: mockEnv,
},
],
}).compile();
const config = module.get(AppConfig);
expect(config.apiUrl).toBe('http://test.api.com');
});
});Testing with Real Config
describe('Integration Test', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.register({
values: { AppConfigValues },
loadDotenv: false, // Don't load .env in tests
}),
],
providers: [AppService],
}).compile();
service = module.get(AppService);
});
it('should work with real config', () => {
expect(service).toBeDefined();
});
});Best Practices
✅ Centralize ConfigValues in a Single File
For better organization, export all your ConfigValues classes from a single file:
// src/config.values.ts
import { ConfigFor, ConfigValues, Env } from '@betternest/config';
import { AppConfig } from './app/app.config';
import { DatabaseConfig } from './database/database.config';
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(private env: Env) {}
apiUrl = this.env.API_URL || 'http://localhost:3000';
port = Number(this.env.PORT) || 3000;
}
@ConfigFor(DatabaseConfig)
export class DatabaseConfigValues implements ConfigValues<DatabaseConfig> {
constructor(private env: Env) {}
host = this.env.DB_HOST || 'localhost';
port = Number(this.env.DB_PORT) || 5432;
}Then register them cleanly in your module:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@betternest/config';
import * as values from './config.values';
@Module({
imports: [
ConfigModule.register({ values }),
],
})
export class AppModule {}This pattern:
- Keeps all configuration values in one place
- Makes registration simple and clean
- Scales well as your app grows
✅ Use Abstract Classes for Models
// ✅ Good - Abstract class (interface)
export abstract class AppConfig {
apiUrl: string;
port: number;
}
// ❌ Avoid - Concrete class or interface
export class AppConfig { }
export interface AppConfig { }✅ Implement ConfigValues Interface
// ✅ Good - Type-safe with interface
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(private env: Env) {}
apiUrl = this.env.API_URL;
port = Number(this.env.PORT);
}✅ Use Specific Validators
// ✅ Good - Specific validators
@IsUrl()
apiUrl: string;
@IsEmail()
email: string;
// ❌ Avoid - Generic validators
@IsString()
apiUrl: string;✅ Accessing Environment Variables
There are two recommended ways to access environment variables in your ConfigValues classes:
Option 1: Inject Env (Recommended)
The cleanest approach is to inject the Env class through dependency injection:
import { Env } from '@betternest/config';
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
constructor(private env: Env) {}
apiUrl = this.env.API_URL || 'http://localhost:3000';
port = Number(this.env.PORT) || 3000;
}Benefits:
- Clean dependency injection (very NestJS-like)
- Easy to mock in tests
- Type-safe access to environment variables
- No setup required -
Envis automatically provided
Option 2: Use const { env } = process
Alternatively, you can use a reference to process.env:
const { env } = process;
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
apiUrl = env.API_URL || 'http://localhost:3000';
port = Number(env.PORT) || 3000;
}Both patterns ensure environment variables are read AFTER dotenv loads them.
❌ What to Avoid
// ❌ WRONG - Destructuring at top-level happens BEFORE dotenv loads
const { API_URL = 'http://localhost:3000' } = process.env;
@ConfigFor(AppConfig)
export class AppConfigValues implements ConfigValues<AppConfig> {
apiUrl = API_URL; // May be undefined!
}Why this fails: When you destructure process.env at the module's top level, values are captured immediately—before @betternest/config calls dotenv.config().
✅ Provide Default Values
// ✅ Good - Safe with defaults
port = Number(this.env.PORT) || 3000;
// ❌ Avoid - Can be NaN
port = Number(this.env.PORT);✅ Group Config by Domain
src/
├── database/
│ ├── database.config.ts
│ ├── database.config.values.ts
│ └── database.service.ts
├── api/
│ ├── api.config.ts
│ ├── api.config.values.ts
│ └── api.service.ts✅ Use Factory Providers for Async Values
// ✅ Good - Factory provider
ConfigModule.register({
values: { AppConfigValues },
providers: [
{
provide: 'SECRET',
useFactory: async () => await fetchFromVault(),
},
],
})
// ❌ Avoid - Top-level await
const secret = await fetchFromVault(); // Bad!
export class AppConfigValues {
secret = secret;
}✅ Use ConfigModule.forAsync() for Async Module Registration
import { ConfigModule } from '@betternest/config';
@Module({
imports: [
// ✅ Good - Using ConfigModule.forAsync()
MongooseModule.forRootAsync(
ConfigModule.forAsync(DatabaseConfig, 'mongooseOptions')
),
// ❌ Verbose - Manual factory setup
MongooseModule.forRootAsync({
inject: [DatabaseConfig],
useFactory: (config: DatabaseConfig) => config.mongooseOptions,
})
]
})Comparison with @nestjs/config
| Feature | @betternest/config | @nestjs/config | |---------|-------------------|----------------| | Type-safety | ✅ Native with inference | ⚠️ Via generics | | Validation | ✅ class-validator (built-in) | ⚠️ Manual (Joi/Yup) | | Error messages | ✅ Enhanced with details | ⚠️ Basic | | Nested validation | ✅ Recursive with paths | ⚠️ Limited | | Dependency injection | ✅ Constructor injection | ❌ Not supported | | API simplicity | ✅ Single decorator | ❌ Config + Schema | | Async loading | ✅ Via factory providers | ✅ Via factory |
FAQ
Why use abstract classes for models?
Abstract classes provide both type information and runtime metadata for validation, while allowing dependency injection in NestJS.
Can I use interfaces instead of abstract classes?
No, TypeScript interfaces are erased at runtime and don't support decorators. Use abstract classes.
How do I handle secrets?
Use factory providers to fetch secrets asynchronously:
ConfigModule.register({
values: { AppConfigValues },
providers: [
{
provide: 'API_KEY',
useFactory: async () => await fetchFromVault('API_KEY'),
},
],
})Does this work with Docker/Kubernetes?
Yes! Environment variables work the same way in containers. Use .env files for local development and environment variables for production.
What happens if configuration is invalid?
The application will log detailed validation errors and exit with code 1, preventing startup with invalid configuration.
Can I disable validation?
No, validation is always enabled. This is by design - invalid configuration should never be deployed.
Examples
See the examples directory for complete working examples:
- basic-config - Core features: type-safe config, async provider injection, nested validation, and array constraints
- mongoose-config - MongoDB/Mongoose integration with
ConfigModule.forAsync()helper
License
MIT © Mathieu Colmon
Related Packages
- @betternest/workflows - MongoDB-based workflow orchestration
- @betternest/healthcheck - Auto-discovery health checks
Part of the BetterNest ecosystem - Production-proven patterns for NestJS applications.
