Feature blog image

Type-safe environment with TypeScript and Zod

5 min read

In Node.js applications, we often use environment variables for configuration. We can access them via process.env, but we have to be careful. We don't know if the variable we are looking for is actually there, and process.env always returns a string. Therefore, we often need to convert the value to the type we need.

If something is wrong with the environment (e.g., a variable is missing or has the wrong type), we often don't notice it until we use the variable at runtime, and the error message is often not very helpful.

In this post, we will try to solve this problem using TypeScript and Zod.

Sample application

Let's say we have a simple application that loads its environment using the dotenv package. The application requires the following environment variables:

VariableTypeDefault value
HOSTstringlocalhost
PORTnumber3000
EMAILstring-
URLstring-
NODE_ENVenumdevelopment

To specify the environment variables, we create an .env file in the root directory of our project.

.env
Copy

PORT=5000
EMAIL="info@sdorra.dev"
URL="https://sdorra.dev"

Now, we can load the environment variables using dotenv.

app.ts
Copy

import dotenv from "dotenv";
dotenv.config();

After loading the environment variables, we can access them through process.env.

app.ts
Copy

console.log(process.env.PORT);

But here, our problems begin. We are unsure if the variable exists, and we receive it as a string. Therefore, the following code will not successfully pass the TypeScript compiler.

app.ts
Copy

const port: number = process.env.PORT;

error
Type 'string | undefined' is not assignable to type 'number'.

To fix this, we need to verify the existence of the variable and convert it into a number.

app.ts
Copy

if (!process.env.PORT) {
throw new Error("❌ PORT environment variable is not defined.");
}
const port: number = parseInt(process.env.PORT);

We have to do this for every variable we need. This is not only annoying, but also error-prone. If we forget to check a variable, we will only notice it when we run the application.

Type-safe environment

To solve this problem, we will create a type-safe version of process.env.

First, we create a new file called env.ts, which loads, validates and converts our environment variables.

env.ts
Copy

import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const schema = z.object({
// explained later
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(parsed.error.format(), null, 4));
process.exit(1);
}
export default parsed.data;

The code above loads the environment, as we have seen before. After that, it creates a schema with Zod. The schema defines the structure of our environment variables. We will look at the schema in a moment, but for now, we define it as an empty object. Once the schema is defined, we parse the environment variables using the schema. If the parsing fails, we log an error and exit the process. Finally, we export the parsed environment variables.

The beauty of this approach is that we only have to do this once, and we can import it from wherever we want. The best part is that it is completely type-safe.

We can access the environment variables as follows:

app.ts
Copy

import env from "./env";
const port: number = env.PORT;

We no longer need to check if the variable exists or convert it. Zod handles it for us, and TypeScript no longer yells at us.

If an environment variable is missing or has the wrong format, we will encounter an error upon application startup. Now let's take a look at the schema.

env.ts
Copy

const schema = z.object({
HOST: z.string().nonempty().default("localhost"),
PORT: z.coerce.number().int().positive().default(3000),
EMAIL: z.string().nonempty().email(),
URL: z.string().nonempty().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

  • HOST: The host is a string and cannot be empty. If the variable is missing, we use the default value localhost.
  • PORT: The port must be converted to a number. We use the coerce method for this. The port must be an integer and positive. If the variable is missing, we use the default value 3000.
  • EMAIL: The email is a string and cannot be empty. It must be a valid email address.
  • URL: The URL is a string and cannot be empty. It must be a valid URL.
  • NODE_ENV: The node environment is an enum and must be one of the following values: development, production or test. If the variable is missing, we use the default value development.

As we can see, Zod makes it really simple to define a complex schema for our environment variables, and this is just the tip of the iceberg for what can be done with Zod. If you want to learn more about Zod, check out the documentation or the wonderful course by Matt Pocock.

Conclusion

In this post, we have seen how to create a type-safe version of process.env with TypeScript and Zod. This approach is not only type-safe but also makes working with environment variables easier.

The approach we have used in this article works fine for most applications, but there are edge cases where it does not work as well, especially in frameworks where the environment is used on both the server and the client. In these cases, it is better to use a library designed specifically for this purpose, such as T3 Env.

Posted in: typescript, zod, environment