Harness the power of TypeScript + Zod to manage your environment variables

Harness the power of TypeScript + Zod to manage your environment variables

February 28, 2024

Environment variables
zod
TypeScript
codequality
validation
runtime-errors

Before starting

In this guide I will assume that we already have our project with TS and Zod configured correctly.

Defining the variables inside .env file

VITE_ENVIRONMENT=offline
VITE_API_BASE_URL=http://localhost:7000
VITE_JWT_DEV_TOKEN=example_token

Defining the Zod schema

We will define the necessary environment variables for the correct operation of our project (for this guide I will use the Vite nomenclature VITE_), we will take advantage of the power of zod to make certain validations regarding the types of data and possible values that we will support for them, for example we will indicate that the variable called VITE_ENVIRONMENT will only be able to support the following values: "offline", "production", "development", "test".

We will also define the variable VITE_JWT_DEV_TOKEN as optional since it will only be mandatory to be able to assign a token for the offline environment.

import { z } from "zod";
 
const schema = z
  .object({
    VITE_ENVIRONMENT: z.enum(["offline", "production", "development", "test"]),
    VITE_API_BASE_URL: z.string(),
    VITE_JWT_DEV_TOKEN: z.string().optional(),
  });

Enable auto-completion and typing

In this step we will enable capping and autocompletion by extending the base system interface we are using to manage our environment variables (process, import.meta etc).

For process.env

declare global {
  namespace NodeJS {
	interface ProcessEnv extends z.infer<typeof schema> {}
  }
}

For import.meta.env

declare global {
  interface ImportMetaEnv extends z.infer<typeof schema> {}
}

With this we will get that when typing process.env. or import.meta.env. our IDE or editor will be able to show us the available variables together with their possible values and types.

Validation of our environment variables at runtime when starting our app

To do this we will create a small function that will simply use the schema created on the previous step to validate the contents of our environment variables, so that if there is any error when performing such validation will be displayed by console.

export function checkEnvs() {
  const parsed = schema.safeParse(import.meta.env);
 
  if (parsed.success === false) {
    console.error(
      "āŒ Invalid environment variables:",
      parsed.error.flatten().fieldErrors,
    );
 
    throw new Error("Invalid environment variables");
  }
}

This function should be called in the init of our app.

Adding complex validations

Now let's see how we can solve the following use case in which the variable VITE_JWT_DEV_TOKEN should be mandatory when the value of VITE_ENVIRONMENT is offline.

We will use the refine functionality offered by Zod as follows:

const schema = z
  .object({
    VITE_ENVIRONMENT: z.enum(["offline", "production", "development", "test"]),
    VITE_API_BASE_URL: z.string(),
    VITE_JWT_DEV_TOKEN: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.VITE_ENVIRONMENT === "offline") {
        return !!data.VITE_JWT_DEV_TOKEN;
      }
      return true;
    },
    {
      message: "VITE_JWT_DEV_TOKEN is required when VITE_ENVIRONMENT is offline",
      path: ["VITE_JWT_DEV_TOKEN"],
    },
  );

Final content of the env.ts file

import { z } from "zod";
 
const schema = z
  .object({
    VITE_ENVIRONMENT: z.enum(["offline", "production", "development", "test"]),
    VITE_API_BASE_URL: z.string(),
    VITE_JWT_DEV_TOKEN: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.VITE_ENVIRONMENT === "offline") {
        return !!data.VITE_JWT_DEV_TOKEN;
      }
      return true;
    },
    {
      message: "VITE_JWT_DEV_TOKEN is required when VITE_ENVIRONMENT is offline",
      path: ["VITE_JWT_DEV_TOKEN"],
    },
  );
 
// if you use process.env
declare global {
  namespace NodeJS {
	interface ProcessEnv extends z.infer<typeof schema> {}
  }
}
 
// if you use import.meta.env
declare global {
  interface ImportMetaEnv extends z.infer<typeof schema> {}
}
 
export function checkEnvs() {
  // you need parse process.env or import.meta.env
  const parsed = schema.safeParse(import.meta.env);
 
  if (parsed.success === false) {
    console.error(
      "āŒ Invalid environment variables:",
      parsed.error.flatten().fieldErrors,
    );
 
    throw new Error("Invalid environment variables");
  }
}

Benefits

  1. Type Safety: By integrating Zod with TypeScript, you ensure that environment variables are not only present but also of the correct type. This reduces runtime errors and enhances the reliability of the application.

  2. Validations at Compile Time and Runtime: Zod allows for complex validation logic, which can be executed both during compile-time and runtime. This dual-phase validation ensures that your environment variables meet the necessary criteria before the application even starts, preventing potential issues in a production environment.

  3. Improved Developer Experience: The auto-completion and typing support provided by this setup significantly improve the developer experience. When a developer types process.env. or import.meta.env., they immediately see available variables along with their types and possible values, making the development process faster and less prone to errors.

  4. Easy Maintenance and Scalability: With a clear structure and validation in place, adding new environment variables or modifying existing ones becomes straightforward. This makes the project easy to maintain and scale over time.

  5. Runtime Validation Feedback: The function to check environment variables at runtime provides immediate feedback if there are any issues with the environment setup. This immediate feedback loop helps in quickly identifying and fixing configuration issues.

  6. Customizable and Complex Validation Logic: The use of Zod's refine functionality allows for the implementation of complex and customized validation logic. For instance, making certain variables mandatory based on the value of another variable adds an extra layer of control over the environment setup.

  7. Reduced Risk of Misconfiguration: By enforcing strict checks on environment variables, the risk of misconfigurations that could lead to security vulnerabilities or application failures is significantly reduced.

  8. Enhanced Code Readability and Documentation: The explicit definition of environment variables and their types serves as a form of documentation, making the code more readable and understandable for new developers or when revisiting the code after a period.

  9. Environment-Specific Configurations: This setup easily supports different configurations for various environments (e.g., development, production, test), ensuring that each environment is correctly configured with its specific needs.

In conclusion, integrating Zod with TypeScript for managing environment variables provides a robust, error-resistant, and developer-friendly approach to configuration management in software projects.


Sources:


Thanks for reading me šŸ˜Š