open atlas
↑ К треку
NestJS с нуля до senior NEST · 03 · 01

Config-модуль: типизированный, валидируемый env

ConfigModule.forRoot оборачивает dotenv, валидирует env на старте по схеме (fail fast) и отдаёт типизированный ConfigService. Используй registerAs для namespace-конфига и инжекть его через ConfigType — не размазывай process.env.

NEST Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Сервис неделю зелёный в CI и на staging. Ты выкатываешь в прод, и через 200 мс после старта первый запрос на оплату взрывается: getaddrinfo ENOTFOUND undefined. Причина — одна строка в клиенте платежей — new Stripe(process.env.STRIPE_SECRET_KEY) — и в проде эта переменная просто не была задана, так что всё это время она была undefined. На старте ничего не упало, потому что никто не проверял. Процесс спокойно принимал трафик с пустой конфигурацией и взорвался только тогда, когда реальный клиент попал на единственный путь кода, использующий недостающее значение. Фикс — не «не забудь задать переменную», а заставить процесс отказываться стартовать, когда его конфигурация неверна.

ConfigModule оборачивает dotenv и централизует чтения

@nestjs/config — тонкий, мнениевый слой поверх dotenv (библиотека, которая читает .env-файл и подкладывает переменные в process.env). Ты регистрируешь его один раз через ConfigModule.forRoot(); он читает .env.env.${NODE_ENV}, если указать на него), подмешивает process.env, опционально валидирует результат и отдаёт всё через инжектируемый ConfigService. Пометка isGlobal: true означает, что любой feature-модуль может инжектить ConfigService без повторного импорта ConfigModule, а cache: true мемоизирует чтения, чтобы config.get() не перечитывал process.env при каждом вызове.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,                              // инжектить ConfigService где угодно, без реимпорта
      cache: true,                                 // мемоизировать чтения process.env
      envFilePath: `.env.${process.env.NODE_ENV}`, // файл на окружение
      expandVariables: true,                       // интерполяция ${HOST}:${PORT} в .env
      ignoreEnvFile: process.env.NODE_ENV === 'production', // прод: только реальный env, без файла
    }),
  ],
})
export class AppModule {}

Дальше ты читаешь значения типизированным геттером — config.get<string>('DATABASE_URL') — вместо обращения к process.env напрямую. Выигрыш не в синтаксисе; он в том, что ConfigService — настоящий provider. Он инжектируемый, поэтому участвует в DI; он мокается, поэтому тесты могут передать фейковую конфигурацию вместо мутации глобального process.env; и поскольку чтения централизованы, валидация происходит ровно в одном месте.

В проде ставь ignoreEnvFile: true. Закоммиченный .env никогда не должен быть источником истины в проде — значения приходят из реального окружения (оркестратора, secrets-менеджера, платформы), а чтение устаревшего файла — это как раз способ протащить staging-креды в прод-деплой.

Сеньорская мысль: валидируй на старте, fail fast

Спроси себя: когда должен упасть неправильно сконфигурированный деплой — на старте, где инженер увидит это в логе деплоя, или через три часа, когда клиент попадёт на сломанный путь кода? Ответ определяет всё, что идёт дальше.

Определяющая фича ConfigModule — не загрузка, а валидация. Передай validationSchema (Joi — библиотека декларативной валидации объектов), и Nest валидирует слитое окружение один раз, на init модуля. Если обязательная переменная отсутствует или сломана, процесс выбрасывает на bootstrap с точным сообщением и вообще не стартует. Это превращает скрытый undefined времени запроса в громкий, немедленный сбой времени деплоя.

import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
    PORT: Joi.number().port().default(3000),
    DATABASE_URL: Joi.string().uri().required(),   // нет -> старт падает
    STRIPE_SECRET_KEY: Joi.string().required(),    // баг из хука, пойманный на старте
  }),
  validationOptions: { abortEarly: false },        // сообщить про ВСЕ плохие переменные, не только первую
});

Предпочитаешь Zod или кастомную validate-функцию, если не хочешь тащить Joi — Nest принимает любую validate: (config) => validatedConfig, которая выбрасывает при провале. Она выполняется в той же точке lifecycle и даёт ту же гарантию fail-fast:

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),                  // выбросит на старте, если нет / сломано
});

ConfigModule.forRoot({
  isGlobal: true,
  validate: (raw) => envSchema.parse(raw),         // ZodError -> процесс отказывается стартовать
});

В любом случае контракт один: сломанный DATABASE_URL роняет старт с понятным сообщением, а не молча остаётся undefined до первого запроса в проде.

Namespace-конфиг через registerAs и типизированная инъекция

Плоский мешок строк config.get('DATABASE_HOST') не масштабируется — ключи stringly-typed, а группировка неявная. registerAs('database', factory) определяет namespace: именованный объект конфигурации, собранный из process.env, зарегистрированный через forFeature или массив load и — главное — инжектируемый как строго типизированная единица.

// database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST ?? 'localhost',
  port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
  url: process.env.DATABASE_URL,
}));
// любой provider
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './database.config';

@Injectable()
export class DbService {
  constructor(
    @Inject(databaseConfig.KEY)
    private readonly dbConfig: ConfigType<typeof databaseConfig>,
  ) {}

  connect() {
    // dbConfig.port типизирован как `number`, dbConfig.host — `string` — без кастов, автокомплит работает
    return `${this.dbConfig.host}:${this.dbConfig.port}`;
  }
}

ConfigType&lt;typeof databaseConfig> выводит статический тип прямо из возвращаемого значения фабрики, а databaseConfig.KEY — это DI-токен для этого namespace. В итоге конфиг типизирован (компилятор знает, что portnumber), валидирован (namespace собран из env, прошедшего схему) и тестируем (инжектишь заглушку для databaseConfig.KEY в юнит-тесте). Сравни с process.env.DATABASE_PORT, размазанным по кодовой базе: нетипизировано (всегда string | undefined), невалидировано, нетестируемо и невидимо для DI.

process.env vs ConfigService vs самописный синглтон

Почему просто не читать process.env или не собрать один объект config.ts самому? Таблица делает разрыв конкретным:

СвойствоСырой process.envСамописный синглтонConfigService
ТипыВсегда string | undefinedТолько если ты их напишешьТипизировано через ConfigType / get<T>
ВалидацияНет — падает при первом использованииСам, легко забытьСхема на старте — fail fast
DI / инжектируемостьНет — глобальный доступНет — импортируемый глобалДа — настоящий provider
Мокается в тестахМутировать глобальный env (течёт)Тяжело — состояние уровня модуляДа — подсунуть фейк

Самописный синглтон даёт типы и одно место чтения, но это состояние уровня модуля, которое нельзя инжектить или чисто мокать, а валидация — это та дисциплина, которую ты не забыл написать. ConfigService даёт все три — типы, валидацию на старте и DI — бесплатно.

Почему это работает

Почему валидация принадлежит старту, а не первому чтению? Проверка времени запроса срабатывает только на пути кода, который трогает переменную, — на единственном маршруте, вызывающем Stripe, на единственной задаче, открывающей БД. Пока этот путь не выполнится в проде, недостающая переменная невидима, поэтому сбой всплывает спустя часы после деплоя, на клиенте, далеко от деплоя, который его вызвал. Валидация на старте схлопывает эту дистанцию: процесс либо стартует с полной, корректной конфигурацией, либо не стартует вовсе — а это ровно тот сигнал, на который умеет реагировать деплой-пайплайн (новые поды не становятся healthy, выкат останавливается).

Выбери лучший вариант

Ты поднимаешь конфиг для нового Nest-сервиса. DATABASE_URL и STRIPE_SECRET_KEY обязательны, и тебе нужна типобезопасность плюс гарантия, что неправильно сконфигурированный деплой никогда не дойдёт до клиентов. К чему ты тянешься?

Викторина

Обязательный DATABASE_URL отсутствует в проде. Что произойдёт с настроенной validationSchema и без неё?

Викторина

Тебе нужен типизированный объект конфига `database` (host: string, port: number), инжектируемый в provider-ы. Какой подход даёт типы плюс DI?

Вспомните перед уходом
  1. 01
    Что на самом деле делает ConfigModule.forRoot и что тебе дают isGlobal, cache и ignoreEnvFile по отдельности?
  2. 02
    Объясни «валидируй на старте, fail fast» и как это подключается через Joi и кастомную функцию validate.
  3. 03
    Почему registerAs + ConfigType лучше, чем размазывать process.env, и как инжектить namespace?
Итог

Config-модуль — это то, как Nest-сервис перестаёт относиться к переменным окружения как к окружающим глобальным строкам и начинает относиться к ним как к типизированному, валидированному, инжектируемому контракту. ConfigModule.forRoot — тонкий слой поверх dotenv: он загружает .env (и .env.${NODE_ENV}), сливает process.env, опционально валидирует и отдаёт инжектируемый ConfigService, который ты читаешь через config.get<T>(); isGlobal позволяет любому модулю его инжектить, cache мемоизирует чтения, а ignoreEnvFile: true в проде держит источник истины в реальном окружении, а не в закоммиченном файле. Сеньорская мысль — fail-fast валидация: validationSchema (Joi) или кастомная функция validate (Zod) выполняется один раз на старте, так что недостающий или сломанный DATABASE_URL роняет старт с понятным сообщением — новый деплой никогда не становится healthy — вместо того чтобы скрытно быть undefined до первого запроса, который трогает его в проде. Для структуры registerAs(‘database’, factory) определяет namespace, инжектируемый через @Inject(databaseConfig.KEY), типизированный как ConfigType<typeof databaseConfig>, что даёт конфиг типизированный, валидированный и мокаемый — всё то, чем размазанные, stringly-typed, невалидированные, неинжектируемые чтения process.env не являются. Выбирай ConfigService с валидацией на старте вместо сырого process.env или самописного синглтона. Теперь, когда встретишь сервис, чей конструктор тянется к process.env напрямую, ты знаешь, что поставить вместо него — и почему деплой с этой заменой будет безопаснее того, что она вытеснит.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.