Опирается на правила:
R-VLD-CFG-1…R-VLD-CFG-4иR-VLD-CFG-X1…R-VLD-CFG-X2из Validation Style Guide → раздел 7. Конфигурация.
Важно знать
ConfigModule.forRoot({ validate })— единственная точка валидации конфига. Невалидный конфиг роняет процесс на старте, не ломает первый запрос.- Required-поля — без default.
@IsNotEmpty()без fallback в YAML гарантирует fail-fast.process.env.Xнапрямую для required-конфига — запрещено: нет типизации, нет валидации (R-VLD-CFG-X2).- Nested-структуры требуют
@ValidateNested()+@Type(() => NestedClass)— без@Typeобъект остаётся plain и не валидируется.enableImplicitConversion: trueвplainToInstanceприводит строки окружения к нужному типу (number, boolean) до валидации.- Альтернатива class-validator — zod-схема в
validate; механизм тот же: невалидный конфиг бросает исключение до готовности приложения.- Инжектируемый типизированный конфиг — единственный способ использовать значения в сервисах (
ConfigService.get<AppConfig>('key')или прямой inject класса конфига).
Конфиг — единственное место, где «упасть на старте» лучше, чем «работать с битыми значениями». Невалидный customerId-заголовок в запросе даёт 400 одному клиенту; невалидный PAYMENT_GATEWAY_URL в окружении ломает весь сервис на первой транзакции. Fail-fast через validate ловит проблему до того, как приложение начнёт принимать трафик. Раскрытие правил раздела 7.
Функция validate в ConfigModule
R-VLD-CFG-1: без validate в ConfigModule.forRoot — нет валидации конфига.
// config/app.config.ts
import { IsNotEmpty, IsString, IsInt, Min, Max, validateSync } from 'class-validator';
import { plainToInstance } from 'class-transformer';
export class AppConfig {
@IsString()
@IsNotEmpty()
DATABASE_URL: string;
@IsString()
@IsNotEmpty()
PAYMENT_GATEWAY_URL: string;
@IsInt()
@Min(1)
@Max(200)
DB_POOL_SIZE: number;
@IsString()
@IsNotEmpty()
KAFKA_BROKERS: string;
}
export function validate(env: Record<string, unknown>): AppConfig {
const cfg = plainToInstance(AppConfig, env, { enableImplicitConversion: true });
const errors = validateSync(cfg, { skipMissingProperties: false });
if (errors.length) {
throw new Error(errors.map(e => Object.values(e.constraints ?? {}).join(', ')).join('\n'));
}
return cfg;
}
// app.module.ts
import { ConfigModule } from '@nestjs/config';
import { validate, AppConfig } from './config/app.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate,
}),
],
})
export class AppModule {}
Что происходит при пропущенном PAYMENT_GATEWAY_URL:
plainToInstanceпередаётundefinedв поле.validateSyncвозвращает ошибку@IsNotEmptyдляPAYMENT_GATEWAY_URL.validateбросаетErrorс текстом нарушений.- NestJS прерывает инициализацию. Процесс завершается с ненулевым кодом.
- Healthcheck остаётся красным. Деплой не завершается.
Без validate — сервис стартует, первый платёж падает с TypeError: Cannot read properties of undefined.
Required-поля без default
R-VLD-CFG-2: required-поле не должно иметь fallback — иначе fail-fast теряет смысл.
// ХОРОШО — нет default, нет fallback
export class PaymentConfig {
@IsString()
@IsNotEmpty()
SBER_ACQUIRING_URL: string; // обязателен, без ${SBER_ACQUIRING_URL:-...}
@IsString()
@IsNotEmpty()
SBER_MERCHANT_ID: string;
@IsInt()
@Min(1000)
@Max(30000)
SBER_TIMEOUT_MS: number; // бизнес-предел: не меньше 1 с, не больше 30 с
}
// ПЛОХО — default скрывает отсутствие значения
export class PaymentConfig {
@IsString()
SBER_ACQUIRING_URL: string = 'http://localhost'; // ← маскирует отсутствие в проде
}
Пустой .env с SBER_ACQUIRING_URL= (пустая строка) при @IsNotEmpty() даст ошибку на старте. С = 'http://localhost' — стартует, отправляет транзакции в никуда.
Числовые типы и enableImplicitConversion
Переменные окружения — всегда строки. enableImplicitConversion: true в plainToInstance приводит их к типу поля до валидации.
export class OrderConfig {
@IsInt()
@Min(1)
@Max(1000)
MAX_ITEMS_PER_ORDER: number; // '50' → 50
@IsInt()
@Min(0)
DRAFT_ORDER_TTL_SECONDS: number; // '3600' → 3600
@IsString()
@IsNotEmpty()
ORDER_ID_PREFIX: string; // 'ORD' → 'ORD'
}
Без enableImplicitConversion MAX_ITEMS_PER_ORDER получит строку '50', @IsInt() провалится, хотя значение корректно. С conversion — сначала приведение типа, потом валидация числового ограничения.
Для boolean-флагов:
export class FeatureConfig {
@IsBoolean()
ENABLE_LOYALTY_PROGRAM: boolean; // 'true' → true, 'false' → false
}
enableImplicitConversion приводит 'true'/'false' к boolean. Значения '1'/'0' — нет; если окружение генерирует такие, нужен кастомный transform.
Nested-конфиг — @ValidateNested + @Type
R-VLD-CFG-4: вложенные классы конфига не валидируются без @ValidateNested и @Type.
// config/database.config.ts
export class DatabaseConfig {
@IsString()
@IsNotEmpty()
DB_HOST: string;
@IsInt()
@Min(1)
@Max(65535)
DB_PORT: number;
@IsString()
@IsNotEmpty()
DB_NAME: string;
@IsString()
@IsNotEmpty()
DB_PASSWORD: string;
@IsInt()
@Min(1)
@Max(200)
DB_POOL_SIZE: number;
}
// config/kafka.config.ts
export class KafkaConfig {
@IsString()
@IsNotEmpty()
KAFKA_BROKERS: string;
@IsString()
@IsNotEmpty()
KAFKA_GROUP_ID: string;
@IsInt()
@Min(100)
@Max(60000)
KAFKA_SESSION_TIMEOUT_MS: number;
}
// config/root.config.ts
export class RootConfig {
@ValidateNested()
@Type(() => DatabaseConfig)
database: DatabaseConfig;
@ValidateNested()
@Type(() => KafkaConfig)
kafka: KafkaConfig;
}
export function validate(env: Record<string, unknown>): RootConfig {
// Разложить плоский env по вложенным объектам
const raw = {
database: {
DB_HOST: env.DB_HOST,
DB_PORT: env.DB_PORT,
DB_NAME: env.DB_NAME,
DB_PASSWORD: env.DB_PASSWORD,
DB_POOL_SIZE: env.DB_POOL_SIZE,
},
kafka: {
KAFKA_BROKERS: env.KAFKA_BROKERS,
KAFKA_GROUP_ID: env.KAFKA_GROUP_ID,
KAFKA_SESSION_TIMEOUT_MS: env.KAFKA_SESSION_TIMEOUT_MS,
},
};
const cfg = plainToInstance(RootConfig, raw, { enableImplicitConversion: true });
const errors = validateSync(cfg, { skipMissingProperties: false });
if (errors.length) throw new Error(errors.toString());
return cfg;
}
Без @Type(() => DatabaseConfig) — plainToInstance оставит database как plain-объект {}. @ValidateNested не сможет применить декораторы — DatabaseConfig-правила не выполнятся. Конфиг с пустым DB_HOST стартует.
process.env напрямую — антипаттерн
R-VLD-CFG-X2: прямое чтение process.env для required-конфига обходит валидацию.
// ПЛОХО
@Injectable()
export class ProductService {
private readonly catalogUrl = process.env.CATALOG_SERVICE_URL; // нет валидации
private readonly timeout = Number(process.env.CATALOG_TIMEOUT); // NaN если не задан
}
Что не так:
- Опечатка в имени переменной →
undefined. Сервис стартует. Первый запрос к каталогу:TypeError. Number(undefined)→NaN. HTTP-клиент сNaN-таймаутом ведёт себя непредсказуемо.- Нет единого места, где виден весь конфиг сервиса.
Правильно — типизированный конфиг через ConfigService или прямой inject:
// config/catalog.config.ts
export class CatalogConfig {
@IsString()
@IsNotEmpty()
CATALOG_SERVICE_URL: string;
@IsInt()
@Min(500)
@Max(10000)
CATALOG_TIMEOUT_MS: number;
}
// product.service.ts
@Injectable()
export class ProductService {
constructor(private readonly config: ConfigService) {}
private get catalogUrl(): string {
return this.config.get<string>('CATALOG_SERVICE_URL')!;
}
}
Или — прямой inject класса конфига (если используется registerAs или кастомный provider):
@Injectable()
export class CustomerService {
constructor(
@Inject(CUSTOMER_CONFIG) private readonly config: CustomerConfig,
) {}
}
config.CATALOG_SERVICE_URL — гарантированно не пустой: validate проверила на старте.
Optional-поля
Поля, которые могут быть не заданы, помечаются @IsOptional():
export class SberConfig {
@IsString()
@IsNotEmpty()
SBER_GATEWAY_URL: string; // required
@IsOptional()
@IsString()
SBER_API_KEY_OVERRIDE?: string; // optional — переопределение для тестового стенда
@IsOptional()
@IsInt()
@Min(1000)
SBER_TIMEOUT_MS_OVERRIDE?: number; // optional — если не задан, берётся дефолт из кода
}
@IsOptional() говорит class-validator: если поле undefined или null — пропустить остальные декораторы. Без него @IsString() упадёт на undefined.
undefined — валидное значение «не задано». Код, который использует:
const timeout = this.config.SBER_TIMEOUT_MS_OVERRIDE ?? DEFAULT_SBER_TIMEOUT_MS;
Zod как альтернатива
Механизм validate работает с любой библиотекой валидации. Zod-вариант:
import { z } from 'zod';
const AppSchema = z.object({
DATABASE_URL: z.string().min(1),
PAYMENT_GATEWAY_URL: z.string().url(),
DB_POOL_SIZE: z.coerce.number().int().min(1).max(200),
KAFKA_BROKERS: z.string().min(1),
});
export type AppConfig = z.infer<typeof AppSchema>;
export function validate(env: Record<string, unknown>): AppConfig {
const result = AppSchema.safeParse(env);
if (!result.success) {
throw new Error(result.error.toString());
}
return result.data;
}
z.coerce.number() — аналог enableImplicitConversion: приводит строку к числу до проверки. Fail-fast — тот же: исключение в validate роняет старт.
Выбор между class-validator и zod — вопрос соглашений проекта. Класс с декораторами удобнее там, где конфиг инжектируется как типизированный объект с DI. Zod-схема лаконичнее для плоских конфигов и монорепо без NestJS-DI в части модулей.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
ConfigModule.forRoot() без validate | R-VLD-CFG-X1 | ConfigModule.forRoot({ validate }) |
process.env.X напрямую для required | R-VLD-CFG-X2 | Типизированный конфиг через ConfigService / inject |
Required-поле с default = 'http://localhost' | R-VLD-CFG-2 | Нет default; fail-fast на старте |
Nested-класс без @Type(() => NestedClass) | R-VLD-CFG-4 | @ValidateNested() + @Type(() => NestedClass) |
Number(process.env.TIMEOUT) без проверки | R-VLD-CFG-X2 | @IsInt() @Min(...) TIMEOUT: number в конфиг-классе |
Конфиг-поле с any-типом | R-VLD-CFG-2 | Конкретный тип + соответствующий декоратор |
Куда дальше
- node/where-to-validate.md — общая картина: контроллер, конфиг, домен.
- node/standard-constraints.md — что использовать на каких типах.
- node/custom-constraints.md — переиспользуемые валидаторы через
@ValidatorConstraint. - node/cross-field-validation.md — правила с двумя и более полями на классе.
- node/validation-groups.md — сценарии create/update с разными required-полями.
- node/messages-and-i18n.md — русские сообщения и i18n через nestjs-i18n.
- Resilience Style Guide — таймауты в конфиге клиентов (
R-RES-*).