How to Maintain Environment Variables

Managing environment variables is crucial for developing robust applications, but direct access through process.env.VAR_NAME can lead to inconsistencies across different environments like local development, CI/CD, and production. A better strategy involves centralizing the retrieval and validation of these variables using libraries such as Zod. This method not only enhances code maintainability but also enables early error detection faster. It's a proactive approach to ensure safeguard against deploying faulty code to production environments.

Fail fast: Identify and resolve issues swiftly to avoid prolonged debugging sessions.

Challenges with Environment Variables

Check the following GIF:

Challenges with env variables

The experience is less than ideal, prone to typos and undefined values, necessitating numerous checks to ensure validity. Neglecting these checks might lead to weird stuff like undefinedposts or undefinedusers.

// get-user.ts
const getUser = () => {
   // Results in "undefinedusers" if BUCKET_URL is missing.
   fetch(process.env.BUCKET_URL + 'users')
}

// get-posts.ts
const getUser = () => {
   // Leads to "undefinedposts" if BUCKET_URL is missing.
   fetch(process.env.BUCKET_URL + 'posts')
}

// ...etc.

The real challenge is maintaining references to process.env.BUCKET_URL across the codebase. In complex applications, this task becomes tedious and prone to errors.

Moreover, the lack of type-safety in accessing environment variables (process.env[SOMETHING]) introduces risks of typos and misuse, as TypeScript types them as string | undefined.

Environment variables are always read as string values in the JavaScript ecosystem.

// .env
BUCKET_URL=Some text // string
BUCKET_URL= // empty string
BUCKET_URL=2312313 // becomes "2312313"
// calc.ts
const calc = () => {
   // Risky...
   process.env.BUCKET_URL + 11 // "231231311" it's a string
}

Identifying the essential environment variables needed for an application to run, can be a challenge without clear documentation, slowing down productivity and increasing the learning curve for new developers.

Additionally, performing computations with environment variables can be tricky, as values are treated as strings, potentially leading to unexpected results.

Mocking process.env[SOMETHING] for testing purposes is cumbersome and can destabilize tests if not handled properly. Using a facade for environment variables simplifies mocking and improves test maintainability.

Here you have an article about mocking envs: https://greenonsoftware.com/courses/react-testing-spellbook/mastering-unit-testing/mocking-environment-variables/

So, let's fix these issues!

Crafting a Solution

Begin by installing the Zod library for runtime validation and type inference with npm i zod. Define a basic structure for your environment variables:

type EnvironmentVariables = Partial<{ API_URL: string }>;
// Extend global type definitions to include our variables.
declare global { 
  namespace NodeJS {
    interface ProcessEnv extends UnsafeEnvironmentVariables {}
  }
}

With TypeScript, you'll receive helpful hints, improving the developer experience compared to the earlier GIF. To ensure values meet our expectations, we perform validation:

import { z } from 'zod';

type EnvironmentVariables = Partial<{ BUCKET_URL: string }>;
// Extending global type definitions.
declare global {
  namespace NodeJS {
    interface ProcessEnv extends EnvironmentVariables {}
  }
}

// Define environment variable validation.
const schema = z.object({
  BUCKET_URL: z.string().regex(/^gs:\/\/[a-zA-Z0-9.-]+\.appspot\.com$/),
});

// Parse and validate...
const result = ((): z.infer<typeof schema> => {
  try {
    return schema.parse(process.env);
  } catch {
    throw Error(
      `
        Incorrect environment setup, refer to documentation [URL]. 
        This application requires ${JSON.stringify(
          Object.keys(schema.keyof().Values),
        )} environment variables.
      `,
    );
  }
})();

// Retrieve environment variable by key.
const env = (key: keyof EnvironmentVariables) => result[key];

export { env };

This setup provides type-safety and immediate feedback on configuration issues. Look what the TypeScript doing:

Demo

Testing becomes straightforward, requiring minimal mocking and no cleanup:

jest.mock('./env', () => ({
  env: () => ({ BUCKET_URL: "https://" }),
}));

Incorrect environment configuration results in clear, immediate feedback through exceptions:

Exception feedback Exception feedback

Full Implementation

import { z } from 'zod';

// Define your environment variables.
type EnvironmentVariables = Partial<{ BUCKET_URL: string }>;

declare global {
  namespace NodeJS {
    interface ProcessEnv extends EnvironmentVariables {}
  }
}

// Validate your environment variables.
const schema = z.object({
  BUCKET_URL: z.string().regex(/^gs:\/\/[a-zA-Z0-9.-]+\.appspot\.com$/),
});

// Parse and validate...
const result = ((): z.infer<typeof schema> => {
  try {
    return schema.parse(process.env);
  } catch {
    throw Error(
      `
        Incorrect environment setup, consult the documentation [URL]. 
        This application requires ${JSON.stringify(
          Object.keys(schema.keyof().Values),
        )} environment variables.
      `,
    );
  }
})();

// Function to retrieve environment variables by key.
const env = (key: keyof EnvironmentVariables) => result[key];

export { env };

Summary

By centralizing and validating environment variables, we significantly enhance the developer experience. This setup provides helpful hints, type-safety, validation, and clear error messages, enabling swift issue resolution. This method is applicable for both server and client-side development without modifications.