{
  "$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"
}