Опирается на правила: R-VLD-CFG-1R-VLD-CFG-4 и R-VLD-CFG-X1R-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() без validateR-VLD-CFG-X1ConfigModule.forRoot({ validate })
process.env.X напрямую для requiredR-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-*).