{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiazdjhmwb6efbdsywc52stjp2k7hta3wyeqe3ii2fvfgnqghupthy",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3moiu7fvhdbo2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreifyn7l3jhzlap5ylmxkhrihz4uqy4e3txviivucju6mxj3ifrbbqu"
},
"mimeType": "image/webp",
"size": 171676
},
"path": "/ctrotech/stop-using-processenv-directly-heres-why-47be",
"publishedAt": "2026-06-17T17:35:47.000Z",
"site": "https://dev.to",
"tags": [
"typescript",
"node",
"javascript",
"tutorial",
"Docs",
"GitHub",
"npm",
"@ctroenv"
],
"textContent": "Last month I pushed a new service to staging. Forgot to add `REDIS_URL` to the env file. The server started fine — no crash, no error — but the first request that tried to hit the cache hung forever. Took me 20 minutes and a `strace` to figure out the connection was silently failing.\n\nI've done this enough times now to recognize the pattern: environment variables in Node.js are just `string | undefined`. TypeScript can't help you. Validation is your job. Most teams do it wrong, or don't do it at all.\n\n## The Problem with `process.env`\n\n\n const dbUrl = process.env.DATABASE_URL\n // ^? string | undefined — TypeScript can't help you here\n const port = process.env.PORT\n // ^? string | undefined — also a string, not a number\n\n\nThree things go wrong here:\n\n 1. **No guarantee the variable exists.** It could be `undefined` at any time, and you won't know until runtime.\n 2. **No type information.** `PORT` is a number semantically, but it's a string at runtime. Every consumer has to parse it themselves.\n 3. **No documentation.** What format should `DATABASE_URL` be? What's the default for `PORT`? Who knows — go find the README, which is probably outdated.\n\n\n\n## Three Common Failure Patterns\n\n### 1. Missing Required Vars\n\n\n // src/db.ts\n const pool = new Pool({\n connectionString: process.env.DATABASE_URL, // undefined — oops\n })\n // Error: connect ECONNREFUSED — good luck debugging that\n\n\nNo error at import time. No error at server start. The error surfaces the first time someone hits a database endpoint.\n\n### 2. Wrong Format, Cryptic Errors\n\n\n // .env\n DATABASE_URL=localhost:5432/myapp // forgot the postgres:// prefix\n\n // src/db.ts\n const url = new URL(process.env.DATABASE_URL) // TypeError: Invalid URL\n\n\nOr worse — silent data corruption:\n\n\n\n // .env\n MAX_CONNECTIONS=not-a-number\n\n // src/config.ts\n const max = parseInt(process.env.MAX_CONNECTIONS ?? \"10\", 10)\n // ^? NaN — your connection pool silently uses NaN as max\n\n\n### 3. Type Confusion\n\n\n // .env\n PORT=3000\n\n // src/server.ts\n const port = process.env.PORT // \"3000\" — it's a string!\n app.listen(port + 1) // listens on \"30001\", not 3001\n\n\nEvery `parseInt`, `Number()`, `=== \"true\"`, or `as string` is validation logic you're scattering across your codebase. Most teams don't even do that.\n\n## The Manual Approach\n\nI've written this function before:\n\n\n\n // src/env.ts\n function getEnv(): Env {\n const databaseUrl = process.env.DATABASE_URL\n if (!databaseUrl) throw new Error(\"DATABASE_URL is required\")\n\n const port = parseInt(process.env.PORT ?? \"3000\", 10)\n if (isNaN(port) || port < 1 || port > 65535) {\n throw new Error(\"PORT must be between 1 and 65535\")\n }\n\n const nodeEnv = process.env.NODE_ENV ?? \"development\"\n if (![\"development\", \"production\", \"test\"].includes(nodeEnv)) {\n throw new Error(`Invalid NODE_ENV: ${nodeEnv}`)\n }\n\n const jwtSecret = process.env.JWT_SECRET\n if (!jwtSecret) throw new Error(\"JWT_SECRET is required\")\n if (jwtSecret.length < 32) throw new Error(\"JWT_SECRET must be at least 32 chars\")\n\n return { databaseUrl, port, nodeEnv, jwtSecret } as const\n }\n\n export const env = getEnv()\n\n\nIt works. But it's repetitive, scattered, undocumented, not CI-friendly, and TypeScript can't infer literal types from it.\n\n## Schema-Based Validation\n\nWhat if you could write this instead:\n\n\n\n import { defineEnv, string, number, pick } from \"@ctroenv/core\"\n\n const env = defineEnv({\n DATABASE_URL: string().url().describe(\"PostgreSQL connection URL\"),\n PORT: number().port().default(3000),\n NODE_ENV: pick([\"development\", \"production\", \"test\"] as const).default(\"development\"),\n JWT_SECRET: string().secret().min(32).describe(\"JWT signing secret\"),\n })\n\n\nAnd get all of this automatically:\n\n * **TypeScript infers everything** — `env.PORT` is `number`, `env.NODE_ENV` is `\"development\" | \"production\" | \"test\"`\n * **Validation at definition time** — if `DATABASE_URL` is missing or invalid, it throws immediately\n * **Defaults** — `PORT` defaults to `3000` if not set\n * **Secrets marked** — `JWT_SECRET` is flagged, won't leak into docs or logs\n * **Descriptions** — each variable carries its docs alongside the schema\n\n\n\n## How It Works\n\n`defineEnv()` reads from your source (defaults to `process.env`), runs each value through its validator chain, and collects errors. If any exist, it throws `CtroEnvError` with every error grouped and formatted:\n\n\n\n ● Missing required (1)\n\n DATABASE_URL Add this variable to your .env file or set it in the environment.\n\n ✗ Invalid (1)\n\n CORS_ORIGIN\n Invalid URL\n\n\nNo hunting through logs. You know exactly what's wrong and what to fix.\n\n## Validators at a Glance\n\nFactory | Creates | Coercion\n---|---|---\n`string()` | A string validator | None\n`number()` | A number validator | Coerces `\"3000\"` -> `3000`, rejects `NaN`\n`boolean()` | A boolean validator | Coerces `\"true\"`/`\"1\"` -> `true`, `\"false\"`/`\"0\"` -> `false`\n`pick([...])` | A union of allowed string values | Fuzzy suggestion on typo\n\nString refinements: `.url()`, `.email()`, `.port()`, `.min(n)`, `.max(n)`, `.regex(p)`\nNumber refinements: `.int()`, `.positive()`, `.port()`, `.min(n)`, `.max(n)`\nChainable (all validators): `.default(v)`, `.optional()`, `.describe(t)`, `.secret()`, `.validate(fn)`\n\n## Putting It Together\n\n\n // src/env.ts\n import { defineEnv, string, number, pick } from \"@ctroenv/core\"\n import { loadEnv } from \"@ctroenv/node\"\n\n const env = defineEnv({\n PORT: number().port().default(3000),\n HOST: string().default(\"0.0.0.0\"),\n DATABASE_URL: string().url().describe(\"PostgreSQL connection URL\"),\n JWT_SECRET: string().secret().min(32).describe(\"JWT signing secret\"),\n CORS_ORIGIN: string().url().describe(\"Allowed CORS origin\"),\n NODE_ENV: pick([\"development\", \"production\", \"test\"] as const).default(\"development\"),\n LOG_LEVEL: pick([\"debug\", \"info\", \"warn\", \"error\"] as const).default(\"info\"),\n REDIS_URL: string().url().optional().describe(\"Redis connection URL\"),\n }, { source: loadEnv() })\n\n export { env }\n\n\n\n // src/index.ts\n import express from \"express\"\n import { env } from \"./env\"\n\n const app = express()\n\n app.get(\"/health\", (_req, res) => {\n res.json({ status: \"ok\", uptime: process.uptime(), nodeEnv: env.NODE_ENV, port: env.PORT })\n })\n\n app.listen(env.PORT, env.HOST, () => {\n console.log(`Server running on http://${env.HOST}:${env.PORT}`)\n })\n\n\nIf any env var is missing or invalid, the app fails at import time — not the first time someone hits a route.\n\n## Beyond Validation\n\nOnce your schema exists, the CLI tools layer on:\n\n * `npx ctroenv generate` — creates `.env.example` from your schema\n * `npx ctroenv validate` — fails CI if env drifts from schema\n * `npx ctroenv docs` — generates `ENVIRONMENT.md`\n * Secrets marked with `.secret()` are masked in output and commented out in `.env.example`\n\n\n\nApproach | Type safety | Error clarity | Auto-docs | CI support | Lines per var\n---|---|---|---|---|---\nRaw `process.env` | None | None | No | No | 1\nManual validation | Partial | Depends | No | No | 5-10\nCtroEnv | Full | Grouped | Yes | Yes | 1\n\n## Try It\n\n\n npm install @ctroenv/core\n\n\n\n import { defineEnv, string, number } from \"@ctroenv/core\"\n\n const env = defineEnv({\n DATABASE_URL: string().url(),\n PORT: number().port().default(3000),\n })\n\n\n**Resources:** Docs · GitHub · npm\n\n_Next:Define Once, Trust Everywhere — CtroEnv Deep Dive_",
"title": "Stop Using process.env Directly — Here's Why"
}