Your application throws a TypeError three hours into a Friday evening. The stack trace points to a deeply nested property on a config object. The value is undefined. Someone pushed a .env change to production without updating the validation logic.
This is not a runtime bug. It is a policy failure. You let untrusted data enter your system without inspecting it first.
Why runtime config validation is too late
Most teams validate config reactively. A missing environment variable causes a crash. An invalid URL string propagates until fetch() throws. A misnamed key in a JSON config file sits dormant until the feature that reads it finally activates, days or weeks later.
The problem gets worse as you adopt domain-specific languages within bounded contexts. Each context has its own config shape. A payment service cares about STRIPE_WEBHOOK_SECRET. A notification service cares about SNS_TOPIC_ARN. The shapes differ, but the failure mode is the same: invalid config enters the system, then the system fails when the invalid data finally gets used.
TypeScript does not save you here. process.env returns string | undefined. Your Config interface promises a string, but the interface is a lie until something enforces it.
The fix is to validate config at the boundary, before it reaches runtime.
Schema validation at the system boundary
Treat config as untrusted input, because it is. Environment variables, JSON files, and remote config services should all pass through a schema validator before your application uses them.
This is the shape that works:
- Define a schema that matches your domain’s expectations.
- Parse the raw config through that schema.
- Use the parsed, typed result everywhere else.
If parsing fails, the application exits immediately with a clear error. No partial startup. No config object with missing fields drifting into the rest of your code.
Here is a concrete example using 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);
If STRIPE_SECRET_KEY is missing, the application throws before it mounts any routes. If MAX_RETRY_ATTEMPTS is "fifteen", the parse fails with a clear error. The type of config after parsing is exactly what PaymentConfig describes, not string | undefined.
Per-context schemas, not one global object
When you model config per bounded context, you avoid the temptation to dump every environment variable into a single Config object that every module imports. That global object creates hidden coupling. A change to the notification service’s config requires touching a file the payment service also imports.
Instead, each bounded context defines its own schema:
// 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),
});
Each context parses only what it needs. The payment service does not see notification variables, and vice versa. This mirrors the domain boundaries you have already drawn in your code.
Build-time validation with generated types
Parsing at startup catches most issues, but you can go further. If your config lives in static files, validate them at build time.
Consider a JSON config file that defines feature flags per environment:
{
"features": {
"newCheckout": {
"enabled": true,
"rolloutPercentage": 50
}
}
}
Define a schema, then validate the JSON file as part of your build step:
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 will refuse to compile if the JSON file is malformed. The error surfaces in CI, not in production.
For environment variables, which are inherently runtime data, you cannot fully validate at build time. But you can generate a type-safe accessor that fails fast on 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());
This pattern centralizes failure. There is exactly one place where a missing or invalid environment variable causes a crash, and it happens before your server starts accepting traffic.
Trade-offs: strictness versus deployment friction
Schema validation is not free. The main cost is deployment friction.
If your schema is too strict, a new environment variable added to staging but not yet added to production will prevent deploys. A teammate adds FEATURE_X_ENABLED=false to the staging .env and pushes code that reads it. Production crashes on startup because the variable is missing there.
You have two ways to handle this.
First, use explicit defaults for variables that have safe fallbacks:
const config = z.object({
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});
Second, separate required from optional config clearly. Required fields should represent things the application genuinely cannot operate without. Optional fields should have defaults or be handled as disabled features when absent.
Another trade-off is schema library size. Zod adds roughly 10KB to your bundle. If you are in a constrained environment, consider Valibot or ArkType, which offer similar APIs with smaller footprints. The pattern matters more than the library.
When generated types are not enough
Some teams generate TypeScript types from their config files and call it validated. A type definition does not enforce anything at runtime. If a JSON file changes shape after the types were generated, TypeScript will not catch it unless the file is imported and checked during compilation.
Runtime parsing bridges the gap. The schema is both the validator and the source of truth for types. There is no separate type definition to drift out of sync.
Putting it together in a bounded context
Here is a complete pattern for a single 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() in your entry point, call it once, and pass the result into your context’s initialization. No module outside this context should read process.env directly.
What to do next
Audit your codebase for direct process.env reads outside of config files. Each one is a potential runtime failure waiting for the right deployment to trigger it.
Replace them with per-context schemas. Pick a library, define the shapes, and fail fast on startup. The first time a missing environment variable exits your application cleanly in CI instead of throwing in production, you will know the boundary is working.