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.
TypeScript's biggest blind spot is the thing you need most.
// @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.
// 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
Validate at startup. Crash before traffic. Export types that don't lie.
Parse, don't validate. One line gives you runtime safety AND correct types.
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'
$ 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.
Don't want a dependency? Fine. Do it yourself, but do it right.
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
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.
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',
});
// ! is a promise you can't keep
const key = process.env.API_KEY!;
// TypeScript trusts you
// Runtime doesn't
// Crash happens in production
// Import your validated env
import { env } from './env';
// Already validated at startup
const key = env.apiKey;
// TypeScript knows it exists
// Runtime already confirmed it
// 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
// 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
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
Encrypt your secrets. Validate your config. Trust your types.
curl -fsSL https://getenvv.com/envv | sh