DEV Community

Stack Horizon
Stack Horizon

Posted on

Environment Config the Clean Way

Stop Hardcoding: Start with a Config Layer

Every app needs configuration. API keys, database URLs, feature flags, ports. The lazy way is to sprinkle process.env calls directly in your business logic. That works until you need to test, share config across services, or change a value without redeploying. The clean way is to centralize config into a single, typed, validated module.

The Problem with Raw process.env

Raw environment variables are strings, untyped, and often missing. If you access process.env.PORT and it's undefined, you get a runtime crash later, not a clear error at startup. Also, you can't easily provide defaults or validate that required keys exist. And when you have 20 variables, your code becomes a mess of process.env.SOMETHING scattered everywhere.

The Clean Pattern: Config Module

Create a dedicated config module that reads all environment variables once, applies defaults, validates, and exports a frozen object. Your app imports this object instead of touching process.env directly.

Here's a minimal Node.js example using plain JavaScript:

// config/index.js
const required = ["DATABASE_URL", "API_KEY"];

function loadConfig() {
  const config = {
    port: parseInt(process.env.PORT, 10) || 3000,
    databaseUrl: process.env.DATABASE_URL,
    apiKey: process.env.API_KEY,
    isDev: process.env.NODE_ENV !== "production",
  };

  // Validate required fields
  for (const key of required) {
    if (!config[key]) {
      throw new Error(`Missing required environment variable: ${key}`);
    }
  }

  // Freeze to prevent accidental mutation
  return Object.freeze(config);
}

export const config = loadConfig();
Enter fullscreen mode Exit fullscreen mode

Now in your app:

import { config } from "./config/index.js";

console.log(`Server listening on port ${config.port}`);
Enter fullscreen mode Exit fullscreen mode

Benefits You Get Immediately

  • Early failure: Missing required variables throw at startup, not at 3am.
  • Type coercion: Parse integers, booleans, JSON once in one place.
  • Defaults: Provide sensible fallbacks.
  • Testability: In tests, you can override the config module or pass a mock.
  • Single source of truth: Change a variable name? Update one file.

Adding TypeScript for Extra Safety

If you're using TypeScript, define an interface for your config:

// config/index.ts
interface Config {
  port: number;
  databaseUrl: string;
  apiKey: string;
  isDev: boolean;
}

const loadConfig = (): Config => {
  // ... same logic
  return Object.freeze(config);
};

export const config: Config = loadConfig();
Enter fullscreen mode Exit fullscreen mode

Now you get autocompletion and compile-time checks. No more typos like config.databaseurl.

Handling Nested Config and Secrets

For more complex apps, consider grouping related variables into nested objects:

const config = {
  server: {
    port: parseInt(process.env.PORT, 10) || 3000,
    host: process.env.HOST || "0.0.0.0",
  },
  db: {
    url: process.env.DATABASE_URL,
    poolSize: parseInt(process.env.DB_POOL_SIZE, 10) || 10,
  },
};
Enter fullscreen mode Exit fullscreen mode

For secrets, never log them. Keep them out of your config object's toString or inspect output. Use a dedicated secrets manager in production, but for local dev, .env files are fine.

Loading .env Files Cleanly

Use dotenv to load .env into process.env, but do it in your config module before reading variables:

import dotenv from "dotenv";
dotenv.config();
Enter fullscreen mode Exit fullscreen mode

This keeps the loading logic in one place. In production, your orchestrator (Docker, Kubernetes) sets real env vars, and dotenv simply does nothing if no .env file exists.

Testing with Config

Because your config is a module, you can mock it in tests. For example, with Jest:

jest.mock("./config", () => ({
  config: { port: 1234, databaseUrl: "mock", apiKey: "test" },
}));
Enter fullscreen mode Exit fullscreen mode

Or better, design your functions to accept config as a parameter when needed. That makes pure functions easier to test.

What About Feature Flags?

Feature flags often change at runtime, not at startup. Don't put them in your static config. Use a separate dynamic config service or a simple in-memory store that can be updated. The static config is for immutable values like ports and credentials.

A Practical Checklist

  • [ ] Create a config module that reads all env vars.
  • [ ] Define defaults for optional values.
  • [ ] Validate required values and throw early.
  • [ ] Freeze the config object.
  • [ ] Use TypeScript interfaces for type safety.
  • [ ] Load .env only in the config module.
  • [ ] Never access process.env outside the config module.

Wrapping Up

Centralizing environment config is a small change with big payoff. It makes your app more predictable, easier to test, and less error-prone. Start with a simple module, add validation and types, and you'll thank yourself when you onboard new developers or debug a production issue. Your future self will appreciate the clean separation.

Now go refactor that process.env spaghetti.

Top comments (0)