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.

// 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.example with 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 .env as 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.

Official reference