process.env is not a typed configuration system. It is an object of runtime input where values are strings or missing. The reliable pattern is to read and validate environment variables once during startup, convert them to the types your app needs, and export one immutable config object.
The pattern in one view
Operating system / hosting provider
|
process.env
|
validate and convert once
|
typed appConfig
|
services and routes
After startup, application code should depend on appConfig, not repeatedly read process.env.
Why TypeScript says values may be undefined
This is correct:
const databaseUrl = process.env.DATABASE_URL;
// string | undefined
TypeScript cannot know which variables exist on the machine that eventually runs the process. Adding this declaration hides the uncertainty without validating reality:
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
}
}
That can improve autocomplete, but it can also let a missing production secret reach the first database request. Runtime validation is the proof.
Recommended Zod config
// src/config.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65_535)
.default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error'])
.default('info')
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const appConfig = Object.freeze({
environment: parsed.data.NODE_ENV,
port: parsed.data.PORT,
databaseUrl: parsed.data.DATABASE_URL,
jwtSecret: parsed.data.JWT_SECRET,
logLevel: parsed.data.LOG_LEVEL
});
z.coerce.number() matters because PORT=3000 enters Node as the string "3000". A TypeScript type annotation does not perform that conversion.
Dependency-free version
For a small service, a few explicit helpers can be enough:
function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
}
function integerEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isInteger(value)) {
throw new Error(`${name} must be an integer`);
}
return value;
}
export const appConfig = Object.freeze({
databaseUrl: requireEnv('DATABASE_URL'),
jwtSecret: requireEnv('JWT_SECRET'),
port: integerEnv('PORT', 3000)
});
The important behavior is fail-fast validation, not the choice of library.
Validate before the server listens
Import configuration before starting network listeners or workers:
import { appConfig } from './config.js';
import { createApp } from './app.js';
const app = createApp(appConfig);
app.listen(appConfig.port, () => {
console.log(`Listening on ${appConfig.port}`);
});
A missing secret should fail the deployment health check immediately. It should not wait until a user reaches the one route that needs the secret.
Booleans are a common trap
Every non-empty string is truthy, including "false":
Boolean(process.env.ENABLE_CACHE); // true when value is "false"
Parse the accepted values explicitly:
const booleanText = z.enum(['true', 'false'])
.transform((value) => value === 'true');
For feature flags with more than two states, use an enum such as off, shadow, and on rather than adding several related booleans.
Secrets and public configuration
Keep separate boundaries:
| Value | Server | Browser bundle |
|---|---|---|
| Database URL | Yes | Never |
| JWT signing secret | Yes | Never |
| Public API base URL | Yes | Sometimes |
| Analytics measurement ID | Yes | Usually public |
Framework prefixes such as PUBLIC_ or NEXT_PUBLIC_ usually mean the value may be embedded in browser JavaScript. A prefix is a publication decision, not just naming style.
Test the config boundary
Move parsing into a function so tests do not mutate global environment state:
export function parseEnv(env: NodeJS.ProcessEnv) {
return envSchema.parse(env);
}
import { describe, expect, it } from 'vitest';
import { parseEnv } from './config';
describe('parseEnv', () => {
it('rejects a short JWT secret', () => {
expect(() => parseEnv({
DATABASE_URL: 'https://db.example.com',
JWT_SECRET: 'short'
})).toThrow();
});
});
Test missing required values, invalid numbers, invalid URLs, and production-only requirements.
Deployment checklist
- Keep a
.env.examplewith names and safe placeholders, never real secrets. - Define variables separately for Preview, Test, and Production.
- Validate before listeners and workers start.
- Convert strings to numbers, booleans, URLs, and enums deliberately.
- Never log secret values in validation errors.
- Rotate secrets instead of treating
.envas permanent storage. - Restart or redeploy after configuration changes unless your platform says otherwise.
Typed configuration is valuable because it turns a late, confusing runtime failure into an immediate, specific startup error.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.