tion gate that runs once, guarantees consistency, and prevents downstream architectural drift.
Core Solution
The architecture revolves around a single principle: configuration should be validated, coerced, and frozen at application bootstrap. This guarantees that every module consuming the config receives a type-safe, runtime-accurate object. The implementation leverages @teispace/env, a zero-dependency ESM module designed for Node β₯ 22.12, Bun, Deno, and edge environments.
Step 1: Define the Schema with Built-in Coercers
Instead of reading raw strings, you declare a schema where each key specifies its expected type, constraints, and default behavior. The library provides a chainable coercer API (e.*) that handles parsing, validation, and type narrowing automatically.
import { defineEnv, e } from '@teispace/env';
export const appConfig = defineEnv({
schema: {
SERVER_PORT: e.int({ min: 1024, max: 65535 }).default(8080),
DATABASE_CONN: e.url({ protocol: 'postgresql' }),
MAX_CONCURRENT_TASKS: e.number({ int: true, min: 1 }).default(4),
ENABLE_METRICS: e.boolean().default(false),
LOG_FORMAT: e.enum(['json', 'pretty']).default('json'),
},
});
At this point, appConfig.SERVER_PORT is strictly a number, not a string. The coercion happens synchronously during initialization. If DATABASE_CONN is missing or malformed, the process halts immediately with a structured error report listing every violation simultaneously. This prevents the common "whack-a-mole" debugging cycle where fixing one missing variable reveals the next.
Step 2: Integrate External Validators (Optional)
For complex business rules, you can mix the built-in coercers with any Standard Schema-compliant library (Zod, Valibot, ArkType). The library detects the schema interface and routes validation accordingly.
import { z } from 'zod';
import { defineEnv, e } from '@teispace/env';
export const appConfig = defineEnv({
schema: {
DATABASE_CONN: z.string().url().refine((url) => url.includes('sslmode=require'), 'SSL required'),
CACHE_TTL: e.int({ min: 60 }).default(300),
REGION: e.enum(['us-east-1', 'eu-west-1']).default('us-east-1'),
},
});
Step 3: Enforce Client/Server Boundaries
Client-side bundles cannot safely access server secrets. The library provides defineEnvSplit to explicitly partition configuration. It validates that client variables use the required prefix and injects a runtime proxy that throws if server-only variables are accessed in a browser context.
import { defineEnvSplit, e } from '@teispace/env';
export const env = defineEnvSplit({
clientPrefix: 'PUBLIC_',
server: {
DB_PASSWORD: e.string({ min: 12 }),
STRIPE_SECRET: e.string().secret(),
},
client: {
PUBLIC_API_BASE: e.url(),
PUBLIC_ANALYTICS_ID: e.string(),
},
runtimeEnv: {
DB_PASSWORD: process.env.DB_PASSWORD,
STRIPE_SECRET: process.env.STRIPE_SECRET,
PUBLIC_API_BASE: process.env.PUBLIC_API_BASE,
PUBLIC_ANALYTICS_ID: process.env.PUBLIC_ANALYTICS_ID,
},
});
The runtimeEnv object is mandatory for client builds because bundlers cannot dynamically resolve process.env at runtime. By explicitly mapping values, you ensure the client bundle receives only the whitelisted PUBLIC_ variables, while the server retains full access. The proxy guard intercepts all access patterns (direct, destructuring, Object.keys, JSON.stringify) and prevents accidental leakage.
Architecture Rationale
- Synchronous Initialization: Environment loading must block startup. Async validation introduces race conditions and complicates module initialization.
- Frozen Output: The returned config object is immutable. This prevents accidental mutation by middleware or third-party libraries.
- Explicit Runtime Mapping: Dynamic environment reading is intentionally disabled for client builds to align with static analysis constraints of modern bundlers.
- Standard Schema Interoperability: Avoids vendor lock-in while maintaining a unified validation pipeline.
- Type Inference Mechanics: The output type is fully inferred from the schema definition. Coercers like
e.port() narrow the type to number, e.boolean() to boolean, and e.enum() to a literal union. No manual interfaces, z.infer, or type assertions are required. The compiler and runtime share the same source of truth.
Pitfall Guide
-
Dynamic Key Access in Client Bundles
Explanation: Attempting to read environment variables using computed keys (e.g., env[dynamicKey]) in a browser environment. Bundlers perform static replacement only on literal keys.
Fix: Always use direct property access (env.PUBLIC_API_URL). If dynamic behavior is required, map allowed keys to a constant object at build time.
-
Relying on TypeScript Declaration Merging
Explanation: Augmenting NodeJS.ProcessEnv to claim PORT: number changes compile-time types but leaves the runtime value as a string. This creates a silent type mismatch.
Fix: Use a schema-based loader that physically coerces values. Never trust declaration merging for runtime safety.
-
Using Async Validators for Environment Loading
Explanation: Standard Schema supports async validation, but environment loading must be synchronous to guarantee configuration availability before module execution. Async schemas will throw a clear error or cause initialization deadlocks.
Fix: Restrict environment schemas to synchronous validation rules. Defer async checks (e.g., database connectivity) to application startup hooks.
-
Forgetting to Mark Sensitive Fields
Explanation: Validation errors often log the received value. If a secret is malformed, the raw string appears in CI/CD logs or console output.
Fix: Chain .secret() on coercers for sensitive variables, or rely on the library's heuristic detection (KEY, SECRET, TOKEN, PASSWORD, PRIVATE). The library automatically redacts these in error reports.
-
Assuming Universal Global Environment Objects
Explanation: Writing code that assumes process.env exists everywhere. Cloudflare Workers inject bindings as handler arguments, Deno uses Deno.env.get(), and browsers have no environment object.
Fix: Use the library's runtime abstraction layer. It automatically detects the execution context and routes to the appropriate source, or requires explicit runtimeEnv mapping for edge/client boundaries.
-
Destructuring Server-Only Variables on the Client
Explanation: Using const { DB_PASSWORD, PUBLIC_API } = env; in a client module. Even if unused, the destructuring operation triggers the proxy guard and throws a runtime error.
Fix: Import only the variables you need, or use the split configuration object to ensure client modules only receive the client partition.
-
Ignoring Build-Time Prefix Requirements
Explanation: Failing to prefix client variables with the framework's required identifier (e.g., VITE_, NEXT_PUBLIC_, PUBLIC_). Bundlers will not inline these variables, resulting in undefined at runtime.
Fix: Configure clientPrefix in defineEnvSplit. The library validates prefix compliance at definition time, catching misconfigurations before deployment.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Node/Bun CLI tool | defineEnv with built-in coercers | Zero dependencies, fast startup, sufficient validation | None |
| Full-stack app with client bundle | defineEnvSplit + explicit runtimeEnv mapping | Prevents secret leakage, aligns with bundler static analysis | Minimal build config adjustment |
| Complex business validation rules | Mix defineEnv with Zod/Valibot/ArkType | Leverages existing validator expertise, maintains sync execution | Slight bundle size increase (if shipped to client) |
| Edge runtime (Cloudflare Workers) | defineEnv with handler binding injection | Adapts to non-global env exposure, maintains type safety | None |
| Legacy monolith migration | Gradual schema adoption with runtimeEnv fallback | Allows incremental rollout without breaking existing process.env reads | Low refactoring effort |
Configuration Template
// src/config/env.ts
import { defineEnvSplit, e } from '@teispace/env';
export const config = defineEnvSplit({
clientPrefix: 'PUBLIC_',
server: {
DATABASE_URL: e.url({ protocol: 'postgresql' }),
REDIS_URL: e.url({ protocol: 'redis' }),
JWT_SECRET: e.string({ min: 32 }).secret(),
LOG_LEVEL: e.enum(['debug', 'info', 'warn', 'error']).default('info'),
},
client: {
PUBLIC_API_URL: e.url(),
PUBLIC_STRIPE_KEY: e.string().secret(),
PUBLIC_FEATURE_FLAGS: e.json<Record<string, boolean>>().default('{}'),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
JWT_SECRET: process.env.JWT_SECRET,
LOG_LEVEL: process.env.LOG_LEVEL,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
PUBLIC_STRIPE_KEY: process.env.PUBLIC_STRIPE_KEY,
PUBLIC_FEATURE_FLAGS: process.env.PUBLIC_FEATURE_FLAGS,
},
});
// Re-export for convenient imports
export const { DATABASE_URL, REDIS_URL, JWT_SECRET, LOG_LEVEL } = config.server;
export const { PUBLIC_API_URL, PUBLIC_STRIPE_KEY, PUBLIC_FEATURE_FLAGS } = config.client;
Quick Start Guide
- Install the package: Run
npm install @teispace/env (requires Node β₯ 22.12, Bun, Deno, or compatible edge runtime).
- Create the schema: Add a
config/env.ts file and define your variables using defineEnv or defineEnvSplit.
- Map runtime values: For client/edge builds, explicitly pass
runtimeEnv to satisfy bundler static analysis requirements.
- Import and use: Replace all
process.env references with the exported config object. The application will fail fast at startup if any variable is missing or malformed.
- Verify in CI: Add a simple script that imports the config module during your pipeline to catch environment drift before deployment.