Skip to Content
Course content

209: Practice Exercise: Building a Type-Safe Environment Variable Loader

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Why can't I just use a type assertion like process.env as Env?

I see this all the time. It's tempting to just define an interface and tell TypeScript, "Trust me, these variables are there," using as Env. The problem is that type assertions are essentially you telling the compiler to stop complaining, but they don't actually change the runtime reality. If you forgot to add DATABASE_URL to your .env file, your app will boot up just fine, but it'll crash the moment it tries to connect to the database.

That's a nightmare to debug in production. Instead, we want "fail-fast" behavior. We want the app to scream and refuse to start if the environment is misconfigured. Here is the difference in approach:

// ❌ The "Trust Me" approach (Dangerous)
interface Config { PORT: string; }
const config = process.env as unknown as Config; 

// ✅ The "Verify" approach (Safe)
function loadConfig(): Config {
  const port = process.env.PORT;
  if (!port) {
    throw new Error("Missing PORT environment variable");
  }
  return { PORT: port };
}


How do I handle variables that need to be numbers or booleans?

Since process.env treats everything as a string (or undefined), you're stuck doing a lot of parseInt or === 'true' checks. If you have ten different numeric variables, your loader becomes a mess of repetitive parsing logic.

The cleanest way to handle this is to define a schema that describes not just the type, but how to transform the string. I usually prefer a simple mapping object for this. Look at how we can handle a PORT number and a DEBUG boolean without writing ten if statements:

type EnvSchema = {
  PORT: (val: string) => number;
  DEBUG: (val: string) => boolean;
  API_KEY: (val: string) => string;
};

const schema: EnvSchema = {
  PORT: (val) => parseInt(val, 10),
  DEBUG: (val) => val === 'true',
  API_KEY: (val) => val,
};

function validateEnv<T>(schema: Record<keyof T, (val: string) => T[keyof T]>): T {
  const result = {} as any;
  for (const key in schema) {
    const value = process.env[key];
    if (value === undefined) {
      throw new Error(`Missing env var: ${key}`);
    }
    result[key] = schema[key](value);
  }
  return result;
}


How do I make the typed config available throughout the app without re-validating?

You definitely don't want to run your validation logic every time you need a variable. That's a waste of resources and makes your code clunky. The trick is to validate once at the entry point of your application and export the resulting object as a constant.

I usually put this in a config.ts file. By exporting a constant, you get full autocomplete across your entire project, and you can be 100% certain that if the code is running, the variables are present and correctly typed.

// config.ts
const schema = {
  PORT: (val: string) => parseInt(val, 10),
  API_KEY: (val: string) => val,
} as const;

// We call the validator immediately
export const ENV = validateEnv(schema);

// In another file (e.g., server.ts)
import { ENV } from './config';
// ENV.PORT is automatically recognized as a number!
app.listen(ENV.PORT, () => console.log('Running...'));



📋 Practical Task

Exercise: Building a Type-Safe Config Loader for a Payment Gateway API

You are building an integration for a payment gateway. The application requires three environment variables to function: STRIPE_SECRET_KEY (string), MAX_RETRIES (number), and ENABLE_WEBHOOKS (boolean).

Your task is to implement a type-safe loader that:

  • Defines a PaymentConfig interface with the correct types.
  • Implements a loadPaymentConfig function that reads from process.env.
  • Throws a descriptive error (e.g., "Missing environment variable: STRIPE_SECRET_KEY") if any of the variables are missing.
  • Correctly casts MAX_RETRIES to a number and ENABLE_WEBHOOKS to a boolean.
  • Exports a constant CONFIG that is the result of this validation.

Test your implementation by mocking process.env with some values and attempting to access CONFIG.MAX_RETRIES to ensure it is treated as a number by the TypeScript compiler.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.