How to Type Environment Variables in TypeScript
You’ve written process.env.API_KEY for the hundredth time, and TypeScript still types it as string | undefined. Your IDE offers no autocomplete. Worse, you deployed to production only to discover a missing variable crashed your app. TypeScript environment variables deserve better handling than this.
This guide shows you how to add type safety to environment variables in modern frontend and full-stack projects—covering both import.meta.env for Vite-based setups and process.env for Node contexts.
Key Takeaways
- Browser apps use build-time injection (variables baked into bundles), while Node.js reads environment variables at runtime—this distinction affects both security and typing strategies.
- Use
ImportMetaEnvdeclarations for Vite projects andNodeJS.ProcessEnvaugmentation for Node contexts to get IDE autocomplete and type checking. - TypeScript types alone can’t prevent runtime crashes—always validate required environment variables at startup using simple checks or schema libraries like Zod.
- Never put secrets in prefixed variables (
VITE_,NEXT_PUBLIC_) since these become visible in client-side bundles.
Understanding Build-Time vs. Runtime Environment Variables
Before typing anything, understand a critical distinction: browser apps and servers handle environment variables differently.
Build-time injection (browser apps): Tools like Vite replace environment variable references with actual values during the build. The variables don’t exist at runtime—they’re baked into your JavaScript bundle.
Runtime environment variables (server-side): Node.js reads process.env from the actual system environment when your code executes. Values can change between deployments without rebuilding.
This distinction matters for security. Any variable injected at build time becomes visible in your client-side code. That’s why frameworks use prefix-based exposure rules—Vite only exposes variables starting with VITE_, and Next.js exposes client-side variables with the NEXT_PUBLIC_ prefix. Private keys without these prefixes stay server-side only.
Typing import.meta.env in Vite Projects
Vite uses import.meta.env instead of process.env. To add type-safe environment variables in TypeScript, create a declaration file:
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
// add more variables here
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Now TypeScript provides autocomplete and treats these as string rather than string | undefined. Place this file in your src directory and ensure your tsconfig.json includes it.
Typing ProcessEnv for Node.js Contexts
For server-side code or tools using process.env, augment the NodeJS.ProcessEnv interface:
// globals.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string
API_SECRET: string
NODE_ENV: 'development' | 'production' | 'test'
}
}
This approach gives you autocomplete across your entire codebase. The NODE_ENV example shows you can use union types for variables with known possible values.
Discover how at OpenReplay.com.
Why Types Alone Aren’t Enough
Here’s the catch: declaration merging tells TypeScript what should exist, not what does exist. You’ve typed DATABASE_URL as string, but if someone forgets to set it, your app crashes at runtime.
TypeScript types are erased at compile time. They can’t validate that environment variables actually exist when your code runs.
Validating Environment Variables at Startup
To validate environment variables in TypeScript, check them early—before your app does anything important. A simple approach:
// config.ts
function getEnvVar(key: string): string {
const value = process.env[key]
if (!value) {
throw new Error(`Missing required environment variable: ${key}`)
}
return value
}
export const config = {
databaseUrl: getEnvVar('DATABASE_URL'),
apiSecret: getEnvVar('API_SECRET'),
} as const
Import this config module at your app’s entry point. If any variable is missing, you’ll know immediately rather than discovering it mid-request.
For more robust validation, libraries like Zod let you define schemas that validate types, formats, and constraints:
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.string().transform(Number).pipe(z.number().min(1)),
})
export const env = envSchema.parse(process.env)
This fails fast with clear error messages if DATABASE_URL isn’t a valid URL or PORT isn’t a number.
Keeping Your Variables Secure
Remember the prefix rules. In Vite projects, only VITE_ prefixed variables reach the browser. Everything else is only available to the build process (server-side) and is not shipped to the browser. Never put secrets in prefixed variables—they’ll be visible in your production bundle.
For server-side secrets, rely on your hosting platform’s environment configuration rather than .env files in production. Add .env to your .gitignore immediately.
Conclusion
Type-safe environment variables in TypeScript require two layers: declaration merging for compile-time autocomplete and runtime validation for production safety. Use ImportMetaEnv for Vite projects, NodeJS.ProcessEnv for Node contexts, and always validate required variables at startup. Your future self—debugging a 3 AM production incident—will thank you.
FAQs
Yes, but keep the declarations separate. Create an env.d.ts file with ImportMetaEnv for your Vite frontend packages and a globals.d.ts with NodeJS.ProcessEnv augmentation for backend packages. Each package's tsconfig.json should only include its relevant declaration file to avoid type conflicts.
Your tsconfig.json likely isn't including the declaration file. Check that your include array covers the file location, or add the file path explicitly. Also verify you're not accidentally shadowing the interface in another declaration file. Restart your TypeScript server after making changes.
For frontend apps, validation at build time is more useful since variables are baked in during the build process. Add a prebuild script that checks required VITE_ variables exist before Vite runs. Runtime validation in the browser can't recover from missing variables anyway.
Mark optional variables with a question mark in your interface declaration, like OPTIONAL_VAR?: string. For validation, use Zod's optional() method or provide default values with default(). Your config object should reflect which variables are truly required versus nice-to-have.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.