金曜日の夜、3時間経った頃、アプリケーションが TypeError を投げた。スタックトレースは設定オブジェクトの深くネストされたプロパティを指している。値は 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);
JSON ファイルが不正な形式であれば TypeScript はコンパイルを拒否する。エラーは本番環境ではなく 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 を追加する。制約の厳しい環境であれば、同様の API をより小さなフットプリントで提供する Valibot や ArkType を検討する。ライブラリよりパターンが重要だ。
生成型だけでは足りない場合
一部のチームは設定ファイルから 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 内でクリーンにアプリケーションを終了させた最初の瞬間に、境界が機能しているとわかる。