Ваше приложение выбрасывает TypeError через три часа после начала пятничного вечера. Stack trace указывает на глубоко вложенное свойство объекта конфигурации. Значение — undefined. Кто-то запушил изменение .env в продакшен, не обновив логику валидации.
Это не рантайм-баг. Это провал политики. Вы позволили недоверенным данным войти в систему без предварительной проверки.
Почему валидация конфигурации в рантайме — слишком поздно
Большинство команд валидируют конфигурацию реактивно. Отсутствующая переменная окружения вызывает краш. Некорректная строка URL распространяется, пока fetch() не выбросит исключение. Неправильно названный ключ в JSON-файле конфигурации бездействует до тех пор, пока функциональность, которая его читает, наконец не активируется — через дни или недели.
Проблема усугубляется, когда вы внедряете предметно-ориентированные языки внутри ограниченных контекстов. У каждого контекста своя форма конфигурации. Платёжный сервису важен STRIPE_WEBHOOK_SECRET. Сервису уведомлений важен SNS_TOPIC_ARN. Формы различаются, но режим отказа один и тот же: некорректная конфигурация попадает в систему, и затем система падает, когда эти данные наконец используются.
TypeScript здесь не спасает. process.env возвращает string | undefined. Ваш интерфейс Config обещает строку, но интерфейс лжёт, пока что-то не заставит его соблюдаться.
Решение — валидировать конфигурацию на границе, до того как она достигнет рантайма.
Валидация схем на границе системы
Воспринимайте конфигурацию как недоверенный вход, потому что это так. Переменные окружения, JSON-файлы и удалённые сервисы конфигурации должны проходить через валидатор схем, прежде чем приложение их использует.
Вот форма, которая работает:
- Определите схему, соответствующую ожиданиям вашего домена.
- Распарсите сырой конфиг через эту схему.
- Используйте распарсенный типизированный результат повсюду.
Если парсинг не удался, приложение немедленно завершается с чёткой ошибкой. Никакого частичного запуска. Никакого объекта конфигурации с отсутствующими полями, который дрейфует в остальную часть кода.
Вот конкретный пример с использованием Zod:
import { z } from "zod";
const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().min(20),
MAX_RETRY_ATTEMPTS: z
.string()
.transform((s) => parseInt(s, 10))
.pipe(z.number().min(1).max(10)),
});
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};
const config = PaymentConfig.parse(raw);
Если отсутствует STRIPE_SECRET_KEY, приложение выбросит исключение до монтирования маршрутов. Если MAX_RETRY_ATTEMPTS — "fifteen", парсинг завершится с чёткой ошибкой. Тип config после парсинга в точности соответствует тому, что описывает PaymentConfig, а не string | undefined.
Схемы по контекстам, а не один глобальный объект
Когда вы моделируете конфигурацию по ограниченным контекстам, вы избегаете соблазна свалить каждую переменную окружения в единый объект Config, который импортирует каждый модуль. Такой глобальный объект создаёт скрытую связанность. Изменение конфигурации сервиса уведомлений требует правки файла, который импортирует и платёжный сервис.
Вместо этого каждый ограниченный контекст определяет собственную схему:
// contexts/payment/config.ts
export const PaymentConfig = z.object({
STRIPE_SECRET_KEY: z.string(),
WEBHOOK_ENDPOINT: z.string().url(),
});
// contexts/notification/config.ts
export const NotificationConfig = z.object({
SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
RATE_LIMIT_PER_MINUTE: z.number().default(100),
});
Каждый контекст парсит только то, что ему нужно. Платёжный сервис не видит переменных уведомлений, и наоборот. Это отражает границы домена, которые вы уже провели в коде.
Валидация во время сборки с генерируемыми типами
Парсинг при запуске ловит большинство проблем, но можно пойти дальше. Если ваша конфигурация находится в статических файлах, валидируйте их во время сборки.
Рассмотрим JSON-файл конфигурации, определяющий функциональные флаги по средам:
{
"features": {
"newCheckout": {
"enabled": true,
"rolloutPercentage": 50
}
}
}
Определите схему, затем валидируйте JSON-файл как часть шага сборки:
import { z } from "zod";
import featureFlags from "./feature-flags.json";
const FeatureFlagConfig = z.object({
features: z.record(
z.object({
enabled: z.boolean(),
rolloutPercentage: z.number().min(0).max(100),
})
),
});
// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);
TypeScript откажется компилироваться, если JSON-файл некорректен. Ошибка возникнет в CI, а не в продакшене.
Переменные окружения по своей природе являются данными рантайма, поэтому их нельзя полностью валидировать во время сборки. Но можно сгенерировать типобезопасный аксессор, который быстро падает при запуске:
function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
const value = process.env[name];
const result = schema.safeParse(value);
if (!result.success) {
console.error(`Invalid env var ${name}:`, result.error.flatten());
process.exit(1);
}
return result.data;
}
const dbUrl = env("DATABASE_URL", z.string().url());
Этот паттерн централизует отказ. Существует ровно одно место, где отсутствующая или некорректная переменная окружения вызывает краш, и это происходит до того, как сервер начнёт принимать трафик.
Компромиссы: строгость против трения при деплое
Валидация схем не бесплатна. Основная цена — трение при деплое.
Если ваша схема слишком строга, новая переменная окружения, добавленная на стейджинг, но ещё не добавленная в продакшен, заблокирует деплои. Коллега добавляет FEATURE_X_ENABLED=false в стейджинговый .env и пушит код, который её читает. Продакшен падает при запуске, потому что переменная там отсутствует.
Есть два способа справиться с этим.
Во-первых, используйте явные значения по умолчанию для переменных, у которых есть безопасные откаты:
const config = z.object({
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});
Во-вторых, чётко разделяйте обязательную и опциональную конфигурацию. Обязательные поля должны представлять вещи, без которых приложение действительно не может работать. Опциональные поля должны иметь значения по умолчанию или обрабатываться как отключённые функции при отсутствии.
Ещё один компромисс — размер библиотеки схем. Zod добавляет примерно 10KB к вашему бандлу. Если вы работаете в ограниченной среде, рассмотрите Valibot или ArkType, которые предлагают аналогичные API с меньшим footprint. Паттерн важнее библиотеки.
Когда генерируемых типов недостаточно
Некоторые команды генерируют типы TypeScript из своих файлов конфигурации и называют это валидацией. Определение типа ничего не принуждает в рантайме. Если JSON-файл меняет форму после генерации типов, TypeScript не поймает это, если только файл не импортирован и не проверен во время компиляции.
Рантайм-парсинг закрывает пробел. Схема одновременно является и валидатором, и источником истины для типов. Нет отдельного определения типа, которое могло бы рассинхронизироваться.
Собираем всё в ограниченном контексте
Вот полный паттерн для одного контекста:
// contexts/billing/config.ts
import { z } from "zod";
const RawBillingConfig = z.object({
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string(),
INVOICE_GRACE_PERIOD_DAYS: z
.string()
.transform(Number)
.pipe(z.number().min(0)),
});
export type BillingConfig = z.infer<typeof RawBillingConfig>;
export function loadBillingConfig(): BillingConfig {
const raw = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
};
const result = RawBillingConfig.safeParse(raw);
if (!result.success) {
const issues = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Billing config invalid:\n${issues}`);
}
return result.data;
}
Импортируйте loadBillingConfig() в точку входа, вызовите её один раз и передайте результат в инициализацию вашего контекста. Ни один модуль за пределами этого контекста не должен читать process.env напрямую.
Что делать дальше
Проведите аудит кодовой базы на предмет прямых чтений process.env вне файлов конфигурации. Каждое из них — потенциальный рантайм-отказ, ожидающий подходящего деплоя, чтобы сработать.
Замените их схемами по контекстам. Выберите библиотеку, определите формы и падайте быстро при запуске. В первый раз, когда отсутствующая переменная окружения корректно завершит ваше приложение в CI вместо того, чтобы выбросить исключение в продакшене, вы будете знать, что граница работает.