process.env.LIES!

TypeScript types everything. Except process.env, which lies to you constantly. It says every key is string | undefined. It's right about the undefined part.

the lie
// TypeScript believes you
const key = process.env.API_KEY!
// Non-null assertion: "trust me bro"
// Runtime disagrees
TypeError: Cannot read property 'length' of undefined

The Lie of process.env

TypeScript's biggest blind spot is the thing you need most.

What TypeScript sees

// @types/node says:
interface ProcessEnv {
  [key: string]: string | undefined;
}

// So TypeScript thinks:
process.env.STRIPE_KEY  // string | undefined
process.env.ANYTHING    // string | undefined
process.env.LIES        // string | undefined

// All equally valid. All equally dangerous.

What runtime sees

// You deployed without STRIPE_KEY
process.env.STRIPE_KEY  // undefined

// Your code assumed it existed
stripe.charges.create({...})
// ^ TypeError at 3am

// The type system couldn't save you
// because you told it not to

Make the type system work for you.

Validate at startup. Crash before traffic. Export types that don't lie.

The Zod Approach

Parse, don't validate. One line gives you runtime safety AND correct types.

src/env.ts

import { z } from 'zod';

// Define what you need
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_KEY: z.string().startsWith('sk_'),
  JWT_SECRET: z.string().min(32),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});

// Parse or crash — no silent failures
export const env = envSchema.parse(process.env);

// TypeScript now knows:
// env.DATABASE_URL  → string (not string | undefined)
// env.STRIPE_KEY    → string (not string | undefined)
// env.PORT          → number (not string | undefined)
// env.NODE_ENV      → 'development' | 'production' | 'test'

What happens when it's wrong

$ envv run -- npx tsx src/index.ts

ZodError: [
  {
    "code": "invalid_string",
    "validation": "url",
    "path": ["DATABASE_URL"],
    "message": "Invalid url"
  },
  {
    "code": "too_small",
    "path": ["JWT_SECRET"],
    "message": "String must contain at least 32 character(s)"
  }
]

// App never starts. You never deploy bad config.
// This error happens in CI, not production.

The Manual Approach

Don't want a dependency? Fine. Do it yourself, but do it right.

src/env.ts — No Dependencies

function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    console.error(`Missing required environment variable: ${key}`);
    console.error('Run with: envv run -- npx tsx ...');
    process.exit(1);
  }
  return value;
}

function optionalEnv(key: string, fallback: string): string {
  return process.env[key] ?? fallback;
}

// Validated, typed config object
export const env = {
  databaseUrl: requireEnv('DATABASE_URL'),
  stripeKey: requireEnv('STRIPE_KEY'),
  jwtSecret: requireEnv('JWT_SECRET'),
  port: parseInt(optionalEnv('PORT', '3000'), 10),
} as const;

// env.databaseUrl is string, not string | undefined
// The const assertion makes it even stricter

Using Your Type-Safe Config

src/db.ts

import { env } from './env';

// No more: process.env.DATABASE_URL!
// No more: process.env.DATABASE_URL ?? ''
// No more: if (!process.env.DATABASE_URL) throw ...

export const db = new Database(env.databaseUrl);
// TypeScript knows it's a string. You validated at startup.

src/stripe.ts

import Stripe from 'stripe';
import { env } from './env';

// The type is string, not string | undefined
// Stripe's types are happy. You're happy.
export const stripe = new Stripe(env.stripeKey, {
  apiVersion: '2023-10-16',
});

TypeScript Traps

The non-null lie

// ! is a promise you can't keep
const key = process.env.API_KEY!;

// TypeScript trusts you
// Runtime doesn't
// Crash happens in production

Validate, then use

// Import your validated env
import { env } from './env';

// Already validated at startup
const key = env.apiKey;

// TypeScript knows it exists
// Runtime already confirmed it

Nested npm scripts

// package.json - BROKEN
{
  "scripts": {
    "start": "tsx src/index.ts",
    "start:prod": "envv run -- npm start"
  }
}
// npm start spawns a new process
// Secrets don't propagate through npm

Direct execution

// package.json - CORRECT
{
  "scripts": {
    "start": "envv run -- tsx src/index.ts",
    "dev": "envv run -- tsx watch src/index.ts"
  }
}
// envv wraps tsx directly
// Secrets in the same process

Full Express Example

src/index.ts

import express from 'express';
import { env } from './env';
import { stripe } from './stripe';
import { db } from './db';

const app = express();

app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    env: env.nodeEnv,
    // Everything here is typed correctly
    // No undefined, no guessing
  });
});

app.listen(env.port, () => {
  console.log(`Server on :${env.port}`);
  console.log(`Environment: ${env.nodeEnv}`);
});

// If you got here:
// 1. env.ts validated all config
// 2. db.ts connected with real credentials
// 3. stripe.ts initialized with real keys
// 4. TypeScript helped the whole way
run
npm install zod express @types/express
envv run -- npx tsx src/index.ts
Server on :3000
Environment: production

Type safety doesn't stop at process.env.

Encrypt your secrets. Validate your config. Trust your types.

curl -fsSL https://getenvv.com/envv | sh