DEV Community

Cover image for Your .env.example is lying to you: catching config drift in TypeScript
Kripa Sindhu
Kripa Sindhu

Posted on AI-assisted

Your .env.example is lying to you: catching config drift in TypeScript

Your .env.example is probably lying to you right now.

Someone added a variable three weeks ago, forgot the template, and nothing broke. No error, no warning, no failing test. The file just quietly stopped describing reality. You find out when a new joiner clones the repo, follows the README exactly, and loses an afternoon to a "works on my machine" that was a missing key the whole time.

Nothing fails. That is precisely the problem.

Boot validation is the easy half

The usual advice is to validate your environment at startup, and it is good advice. Most of us have written some version of this:

const port = Number(process.env.PORT) || 3000;
const url  = process.env.DATABASE_URL!;          // "!" meaning: trust me
if (process.env.DEBUG === "true") { /* ... */ }  // "false" is a truthy string
Enter fullscreen mode Exit fullscreen mode

Every line here is a small bet. PORT=abc becomes NaN, then 3000, and you never hear about it. DATABASE_URL is asserted non-null by a ! that the type system believes and the runtime does not. DEBUG=false is the string "false", which is truthy, so a strict-equality check is the only thing standing between you and a debug build in production.

A schema fixes all of that:

// env.ts
import { defineEnv, str, port, bool, oneOf } from "prahari";

export const env = defineEnv({
  NODE_ENV:     oneOf(["development", "production", "test"]).default("development"),
  PORT:         port().default(3000),
  DATABASE_URL: str().desc("Postgres connection string"),
  STRIPE_KEY:   str().secret().startsWith("sk_"),
  DEBUG:        bool().default(false),
});

env.PORT;      // number
env.NODE_ENV;  // "development" | "production" | "test"
env.DEBUG;     // boolean
Enter fullscreen mode Exit fullscreen mode

Now a bad environment stops the process before it can serve a request, with every problem reported at once rather than one per restart:

prahari: 2 environment variables failed validation

  ✗ DATABASE_URL  (string)  is required but was not set
  ✗ STRIPE_KEY    (string)  must start with "sk_"   received: ***
Enter fullscreen mode Exit fullscreen mode

Note the ***. A rejected secret should never end up in your logs, and a validation error is one of the easiest places for one to leak.

This much is table stakes. envalid, znv, t3-env and a hand-rolled Zod schema all get you here, and any of them is a real improvement over raw process.env.

The half that actually bites

Here is what none of that solves. Your schema is now the source of truth about your configuration, and you have a second file, .env.example, that claims to describe the same thing. Two artifacts, one set of facts, no mechanical relationship between them.

That is a drift generator. It will diverge, because the only thing keeping them in sync is somebody remembering.

So the fix is to stop treating the example file as a document and start treating it as build output:

prahari example    # generate .env.example from the schema
prahari sync       # diff schema against the file, exit 1 on drift
prahari doctor     # validate the environment you are actually running in
prahari docs       # emit a Markdown table for your README
Enter fullscreen mode Exit fullscreen mode

Generation gives you a template with the documentation already in it, because the descriptions live on the schema:

# Postgres connection string
# (required, string)
DATABASE_URL=

# (has default, port)
PORT=3000

# (required, secret, string)
STRIPE_KEY=
Enter fullscreen mode Exit fullscreen mode

And sync turns the drift into a failing check instead of a lost afternoon:

$ prahari sync
✗ .env.example has drifted from your schema:

  + STRIPE_KEY — in schema, missing from file
  - LEGACY_FLAG — in file, not in schema

Run `prahari example` to regenerate.
Enter fullscreen mode Exit fullscreen mode

Wire that into CI and the class of bug disappears. Not "becomes less likely." Disappears, because the only way to merge a drifted file is to ignore a red build.

You do not need my library to get this. If you already use envalid or a Zod schema, you can write thirty lines that walk your schema, render a template, diff it against the file on disk, and process.exit(1). The specific tool matters much less than the idea that the example file should be generated, never edited.

Then I did the exact thing the library exists to prevent

Here is the part I did not enjoy.

prahari ships a CLI. The CLI needs to evaluate your schema in order to generate a template, report drift, or validate the current environment. At some point, for reasons that felt entirely sensible in the moment, the CLI grew its own copy of the validation logic.

A second implementation of the same rules. Living about forty lines from the first one.

You already know what happened. The two implementations drifted. prahari doctor began crashing on a schema that the library itself handled perfectly well, because the CLI's copy had missed a case the real evaluator had learned to handle. A library whose entire pitch is "two artifacts describing one truth will diverge" had two artifacts describing one truth, and they diverged.

A review on the pull request caught it before release, which is the only reason this is a blog post rather than a bug report.

The fix was not to patch the copy. It was to delete the copy and make the CLI call the same evaluator as everything else. That is now a rule in the project: one evaluator, one place, no exceptions. Every consumer, whether library, CLI, or test, goes through the same function.

The actual lesson

Config drift is not really about config. It is a specific case of a general rule:

Two implementations of one rule will drift. Not might. Will. The only reliable fix is to have one.

The reason it keeps catching us is that duplication is cheap at the moment you create it and expensive only later, at a distance, in a way that never points back at the decision that caused it. The second copy always looks like the pragmatic choice on the day you write it.

And notice that being aware of the problem bought me nothing. I was writing a tool about drift, thinking about drift full time, and I still shipped a duplicate evaluator into it. Knowing the rule is not the mechanism. The mechanism is either "there is only one implementation" or "something automated fails when the copies disagree."

What this isn't

Being honest about the boundaries, since that is the part most library posts skip:

  • It does not manage secrets. It validates that STRIPE_KEY is present and well-formed, not that it is the right key. Use a real secret manager.
  • It does not do runtime reloading. Environment is read and frozen at boot, deliberately, because a config that changes under a running process is a different and harder problem.
  • The built-in validators are intentionally small. If you need real validation power, bring Standard Schema and use Zod, Valibot, or ArkType instead. The built-ins exist so the zero-dependency path works, not to compete with them.

If you want to try it

npm i prahari
Enter fullscreen mode Exit fullscreen mode

Node 18+, MIT, zero runtime dependencies on the import path, ESM and CJS with correct types for both, and a public API frozen by contract tests that fail the build if an export is renamed or removed.

But honestly, if you take one thing from this, take the rule and go looking for your own duplicate evaluator. I promise there is one.

Top comments (1)

Collapse
 
kripasindhu007 profile image
Kripa Sindhu

Looking forward to answer your questions. Any constructive feedbacks are also welcome.😊