29.6 Typing process.env with a Custom Type Declaration
Right, so you’ve decided to live in a world where your environment variables aren’t just mysterious string | undefined gremlins hiding in your process.env object. Good choice. It’s a small piece of TypeScript hygiene that pays off massively by turning runtime configuration errors into compile-time squiggles. Let’s get it done properly.
The core problem is that process.env is, by default, typed by Node.js’s built-in types as a ProcessEnv interface where every property is optional (string | undefined). This is the technically correct, safety-first stance for the Node.js library itself, but it’s infuriating for us because we know we’ve set DATABASE_URL in our .env file. We want to access process.env.DATABASE_URL and have TypeScript know it’s a string, period.
The Custom Declaration File (.d.ts)
The cleanest, most maintainable way to solve this is by creating a custom type declaration file. We’re not going to monkey-patch the global ProcessEnv interface from within our application code—that’s a bit sloppy. Instead, we’ll use TypeScript’s declaration merging feature, which is precisely designed for this kind of thing.
Create a new file in your project, typically in your src or root directory. I like to call it something obvious, like env.d.ts.
// src/env.d.ts
namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'production' | 'test';
readonly PORT: string;
readonly DATABASE_URL: string;
readonly API_KEY: string;
// ... add other environment variables here
}
}
Here’s the magic: because we’re declaring the same NodeJS namespace and the same ProcessEnv interface within it, TypeScript will automatically merge our declarations with the built-in ones. Our version adds specific, required properties. Now, whenever you access process.env.NODE_ENV, TypeScript will know it’s not just any string, but one of those three literal values.
Why This is the Best Practice
This approach is superior to casting values everywhere (process.env.API_KEY as string) for one simple reason: it creates a single source of truth. You define the contract for your environment variables in one place. If you need to change a variable name or add a new one, you do it here. The type safety propagates to every single file in your project instantly. Trying to access an undefined variable? Red squiggle. Forgetting to add a new required variable to your deployment setup? Red squiggle before you deploy.
The Inevitable “But What About Defaults?!”
Ah, the classic dance. You have PORT: string, but what if you want a default value, like 3000, if it’s not set? I need to stop you right there. Your type declaration should not define runtime behavior. Its only job is to describe the shape of process.env after your environment has been set up.
Your runtime logic, usually in the main entry point of your app, should handle the coalescing of values. This separation of concerns is critical.
// src/app.ts (or index.ts)
import express from 'express';
const app = express();
// This happens at runtime. The type system has already done its job.
const port = process.env.PORT || '3000';
app.listen(port, () => {
console.log(`Server rocking and rolling on port ${port}`);
});
Notice that we use || and not ??. Why? Because process.env will set an unset variable to undefined, but it will set an empty string to, well, ''. The logical OR (||) catches both undefined and '' as falsy, which is almost always what you want. The nullish coalescing operator (??) would only catch undefined and let the empty string through, which is probably not helpful. This is the kind of trench-knowledge that saves you an hour of debugging a “connection refused” error.
The Gotcha: Keeping It In Sync
The one admittedly slightly tedious part of this whole setup is that your env.d.ts file is now a piece of configuration that must be manually kept in sync with your actual .env file (or your .env.example template). There are tools that can generate this file for you, but for most projects, the number of environment variables is small enough that the manual maintenance is a trivial cost compared to the benefit of total type safety. Think of it as writing your tests after you’ve already defined the API contract—it’s a good forcing function to actually think about what your app needs to run.
So, go forth. Declare your environment. Stop casting as string like a barbarian. Your brilliant future self, the one who isn’t debugging a Cannot read properties of undefined error at 2 AM, will thank you.