Aplikasi kamu melempar TypeError tiga jam setelah Jumat malam dimulai. Stack trace menunjuk ke properti yang bersarang dalam di objek config. Nilainya adalah undefined. Seseorang push perubahan .env ke produksi tanpa memperbarui logika validasi.

Ini bukan bug runtime. Ini adalah kegagalan kebijakan. Kamu membiarkan data yang tidak tepercaya masuk ke sistem tanpa memeriksanya terlebih dahulu.

Mengapa validasi config saat runtime terlalu lambat

Sebagian besar tim memvalidasi config secara reaktif. Variabel environment yang hilang menyebabkan crash. String URL yang tidak valid menyebar hingga fetch() melempar. Key yang salah nama dalam file config JSON tetap diam hingga fitur yang membacanya akhirnya aktif, berhari-hari atau berminggu-minggu kemudian.

Masalahnya semakin parah saat kamu mengadopsi domain-specific language dalam bounded context. Setiap context memiliki bentuk confignya sendiri. Layanan pembayaran peduli pada STRIPE_WEBHOOK_SECRET. Layanan notifikasi peduli pada SNS_TOPIC_ARN. Bentuknya berbeda, tetapi mode kegagalannya sama: config yang tidak valid masuk ke sistem, lalu sistem gagal saat data yang tidak valid akhirnya digunakan.

TypeScript tidak menyelamatkanmu di sini. process.env mengembalikan string | undefined. Interface Config-mu menjanjikan string, tetapi interface itu bohong sampai ada sesuatu yang menegakkannya.

Solusinya adalah memvalidasi config di boundary, sebelum mencapai runtime.

Validasi skema di boundary sistem

Perlakukan config sebagai untrusted input, karena memang begitu. Variabel environment, file JSON, dan layanan config remote semuanya harus melewati validator skema sebelum aplikasi menggunakannya.

Ini adalah bentuk yang berfungsi:

  1. Definisikan skema yang cocok dengan ekspektasi domainmu.
  2. Parse config mentah melalui skema itu.
  3. Gunakan hasil yang sudah di-parse dan diberi tipe di tempat lain.

Jika parsing gagal, aplikasi langsung keluar dengan error yang jelas. Tidak ada startup parsial. Tidak ada objek config dengan field yang hilang melayang ke bagian lain dari kode.

Berikut adalah contoh konkret menggunakan 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);

Jika STRIPE_SECRET_KEY hilang, aplikasi melempar sebelum memasang route apa pun. Jika MAX_RETRY_ATTEMPTS adalah "fifteen", parse gagal dengan error yang jelas. Tipe config setelah parsing adalah persis seperti yang dijelaskan PaymentConfig, bukan string | undefined.

Skema per-context, bukan satu objek global

Ketika kamu memodelkan config per bounded context, kamu menghindari godaan untuk membuang setiap variabel environment ke dalam satu objek Config yang setiap module import. Objek global itu menciptakan coupling tersembunyi. Perubahan pada config layanan notifikasi mengharuskan menyentuh file yang juga diimpor oleh layanan pembayaran.

Sebaliknya, setiap bounded context mendefinisikan skemanya sendiri:

// 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),
});

Setiap context hanya parse apa yang dibutuhkannya. Layanan pembayaran tidak melihat variabel notifikasi, dan sebaliknya. Ini mencerminkan batas domain yang sudah kamu buat dalam kode.

Validasi saat build dengan tipe yang dihasilkan

Parsing saat startup menangkap sebagian besar masalah, tetapi kamu bisa lebih jauh lagi. Jika config berada di file statis, validasi saat build.

Pertimbangkan file config JSON yang menentukan feature flag per environment:

{
  "features": {
    "newCheckout": {
      "enabled": true,
      "rolloutPercentage": 50
    }
  }
}

Definisikan skema, lalu validasi file JSON sebagai bagian dari langkah build:

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 akan menolak untuk mengompilasi jika file JSON malformed. Error muncul di CI, bukan di produksi.

Untuk variabel environment, yang secara inheren adalah data runtime, kamu tidak bisa memvalidasi sepenuhnya saat build. Tetapi kamu bisa menghasilkan accessor yang type-safe yang gagal cepat saat startup:

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());

Pola ini memusatkan kegagalan. Ada tepat satu tempat di mana variabel environment yang hilang atau tidak valid menyebabkan crash, dan itu terjadi sebelum server mulai menerima traffic.

Trade-off: ketat versus gesekan deployment

Validasi skema tidak gratis. Biaya utamanya adalah gesekan deployment.

Jika skemamu terlalu ketat, variabel environment baru yang ditambahkan ke staging tetapi belum ke produksi akan mencegah deploy. Rekan menambahkan FEATURE_X_ENABLED=false ke .env staging dan push kode yang membacanya. Produksi crash saat startup karena variabel itu hilang di sana.

Ada dua cara untuk menangani ini.

Pertama, gunakan default eksplisit untuk variabel yang memiliki fallback aman:

const config = z.object({
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});

Kedua, pisahkan config yang wajib dari config opsional dengan jelas. Field yang wajib harus mewakili hal-hal yang tidak bisa dioperasikan oleh aplikasi tanpanya. Field opsional harus memiliki default atau ditangani sebagai fitur yang dinonaktifkan saat tidak ada.

Trade-off lainnya adalah ukuran library skema. Zod menambahkan sekitar 10KB ke bundle. Jika kamu berada di environment yang terbatas, pertimbangkan Valibot atau ArkType, yang menawarkan API serupa dengan footprint lebih kecil. Polanya lebih penting daripada library.

Kapan tipe yang dihasilkan tidak cukup

Beberapa tim menghasilkan tipe TypeScript dari file config mereka dan menyebutnya tervalidasi. Definisi tipe tidak memaksakan apa pun saat runtime. Jika file JSON berubah bentuk setelah tipe dihasilkan, TypeScript tidak akan menangkapnya kecuali file diimpor dan diperiksa selama kompilasi.

Parsing runtime menjembatani kesenjangan itu. Skema adalah validator sekaligus sumber kebenaran untuk tipe. Tidak ada definisi tipe terpisah yang bisa tidak sinkron.

Merangkum dalam bounded context

Berikut adalah pola lengkap untuk satu context:

// 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;
}

Import loadBillingConfig() di entry point, panggil sekali, dan lewatkan hasilnya ke inisialisasi context. Tidak ada module di luar context ini yang boleh membaca process.env secara langsung.

Apa yang harus dilakukan selanjutnya

Audit codebase kamu untuk pembacaan process.env langsung di luar file config. Masing-masing adalah potensi kegagalan runtime yang menunggu deployment yang tepat untuk memicunya.

Ganti dengan skema per-context. Pilih library, definisikan bentuknya, dan gagal cepat saat startup. Saat pertama kali variabel environment yang hilang menutup aplikasi dengan bersih di CI alih-alih melempar di produksi, kamu akan tahu boundary itu bekerja.