You set FEATURE_FLAG=false to turn a feature off, but it stays on — because environment variables are always strings, and the non-empty string "false" is truthy. This guide is part of environment variable validation within the environment sync, secrets and CI parity baseline. Every coercion bug below has the same origin: process.env hands you strings, your code assumes types, and the gap between those two facts surfaces as a feature that will not turn off, a port that will not bind, or a retry count that silently collapses to zero.

The failure mode is insidious because it is not a crash. A string-vs-boolean mistake produces a working program that does the wrong thing, so it clears the build, clears the smoke test, and only reveals itself when someone in production wonders why the flag they flipped had no effect. Parsing at the boundary — turning raw strings into validated, typed values in exactly one place — is the durable fix, and the rest of this guide shows how to build that boundary and how to prove it works.

Diagnostic

Observe how raw env values behave when used directly as booleans and numbers.

#!/usr/bin/env bash
set -euo pipefail
FEATURE_FLAG=false MAX_CONN=08 node -e '
  console.log("flag truthy?", Boolean(process.env.FEATURE_FLAG));
  console.log("conn parsed:", parseInt(process.env.MAX_CONN));
  console.log("retries:", process.env.RETRIES, "->", Number(process.env.RETRIES));
'

Expected BAD output:

flag truthy? true
conn parsed: 8
retries: undefined -> 0

"false" is truthy, a leading zero is silently dropped, and an unset RETRIES coerces to 0 instead of failing or using a real default. Each line is a different flavour of the same class of bug. The first is truthiness: JavaScript treats every non-empty string as true, so Boolean("false"), Boolean("0"), and Boolean("no") are all true. The second is radix ambiguity: parseInt("08") happens to return 8 in modern engines, but parseInt("0x1F") returns 31 and parseInt("11", 8) returns 9 — the function reads prefixes and an optional radix, so a value that looks numeric can parse to something you did not intend. The third is the empty-versus-unset trap: an unset variable is undefined and coerces to NaN, while a present-but-empty variable is "" and coerces to 0, so two states that feel identical produce wildly different numbers.

To see the empty-versus-unset distinction directly, run the same expression with the key present but blank:

#!/usr/bin/env bash
set -euo pipefail
RETRIES= node -e 'console.log(process.env.RETRIES, "->", Number(process.env.RETRIES))'
# prints:  "" -> 0        (empty string, not undefined; coerces to 0, not NaN)
unset RETRIES; node -e 'console.log(process.env.RETRIES, "->", Number(process.env.RETRIES))'
# prints:  undefined -> NaN

A default expressed as Number(process.env.RETRIES) || 3 masks both problems at once and introduces a fourth: because 0 is falsy, a legitimately configured RETRIES=0 also falls through to 3. There is no way to express "retry zero times" with that idiom, which is exactly why boundary parsing has to be explicit rather than relying on JavaScript's coercion rules.

Raw coercion versus validated parsing Comparison of what raw JavaScript coercion produces versus a validated schema for the same three inputs. Raw Coercion vs Validated Parse Raw process.env "false" is truthy - true "08" parseInt - 8, maybe unset - NaN, empty - 0 || default hides zero silent wrong behaviour Validated schema enum - real boolean false coerce.number.int - 8 default only when absent RETRIES=0 stays zero fails loud at startup
The same three inputs: raw coercion produces silent wrong values; a schema produces typed values or a startup error.

Root cause

Every environment variable is a string. Boolean("false") is true because any non-empty string is truthy. Numbers need explicit parsing, and Number("") is 0 while Number(undefined) is NaN — so empty-string and unset behave differently and both are easy to mistake for a valid default. The fix is to parse and validate at the boundary instead of consuming raw process.env strings throughout the app.

"At the boundary" is the load-bearing phrase. process.env is untyped input from outside your program, no different in principle from an HTTP request body or a row from a database. You would never scatter JSON.parse calls across a codebase and hope each caller handles a malformed payload correctly; you parse once, at the edge, into a typed object, and pass that object inward. Configuration deserves the same discipline. When every module reads process.env.MAX_CONN and coerces it locally, a coercion bug can hide in any of them and each site can be subtly different. When a single config module parses the whole environment once and exports typed constants, there is exactly one place to get the coercion right and one place to look when it is wrong.

The boundary also concentrates policy. Questions like "which strings count as true", "is a leading zero an error or a value", and "does an empty string mean the default or a hard failure" have to be answered somewhere. Spread across the codebase, those answers drift and contradict each other. Centralised in a schema, they become a single, reviewable contract that the rest of the program can trust without re-checking.

Parsing at the boundary Raw strings from process.env pass through a single schema and emerge as typed configuration for the whole app. Parse Once at the Edge process.env raw strings only Schema parse coerce + validate Typed config bool, number, enum Downstream code imports typed config, never process.env directly.
One schema at the edge converts untyped strings into typed configuration the rest of the app can trust.

Resolution

  1. Coerce and validate with a schema so every variable arrives as the correct type. Zod's coerce and explicit boolean parsing remove the ambiguity.
// config/env.ts
import { z } from 'zod';

const boolish = z
  .enum(['true', 'false', '1', '0'])
  .transform((v) => v === 'true' || v === '1');

export const Env = z.object({
  FEATURE_FLAG: boolish.default('false'),
  MAX_CONN: z.coerce.number().int().positive().default(10),
  RETRIES: z.coerce.number().int().min(0).default(3),
}).parse(process.env);

The boolish helper is deliberately strict: it accepts only the four tokens true, false, 1, and 0, and rejects anything else with a validation error rather than guessing. That strictness is a feature — a typo like FEATURE_FLAG=ture fails at startup with a message naming the field, instead of coercing to false and quietly disabling the feature. If your team genuinely needs to accept yes/no or on/off, widen the enum explicitly so the accepted set stays visible in the schema rather than buried in ad-hoc parsing logic.

For the numbers, z.coerce.number() runs the value through Number() first, then .int() rejects fractional input and .positive() (or .min(0)) enforces the domain. Note the difference between MAX_CONN and RETRIES: connections must be at least one, so .positive() rejects zero; retries may legitimately be zero, so .min(0) allows it. Encoding that distinction in the schema is what prevents the || default bug — a configured RETRIES=0 survives because the default only applies when the key is truly absent.

  1. Or use envalid, which rejects malformed values at startup with a clear report.
// config/env-envalid.ts
import { cleanEnv, bool, num } from 'envalid';

export const env = cleanEnv(process.env, {
  FEATURE_FLAG: bool({ default: false }),
  MAX_CONN: num({ default: 10 }),
  RETRIES: num({ default: 3 }),
});

envalid's bool() validator already understands true, false, 1, and 0, and its default handling distinguishes unset from empty the way you want. On a validation failure it prints a formatted table of every offending variable and exits with a non-zero code, which makes a misconfigured deploy fail immediately and legibly rather than deep inside request handling. Choose whichever library your stack already uses; the important property is that both parse once, centrally, and both reject bad input at the edge.

  1. For shell scripts, compare against an explicit allowlist rather than relying on truthiness.
#!/usr/bin/env bash
set -euo pipefail
case "${FEATURE_FLAG:-false}" in
  true|1) echo "feature enabled" ;;
  false|0|"") echo "feature disabled" ;;
  *) echo "FATAL: FEATURE_FLAG must be true/false, got '$FEATURE_FLAG'" >&2; exit 1 ;;
esac

Shell has its own truthiness traps: [ "$FEATURE_FLAG" ] is true for any non-empty string including false, exactly mirroring the JavaScript bug, and [ "$FEATURE_FLAG" = true ] silently treats a typo as disabled. The case statement above enumerates the accepted tokens, treats unset and empty as the safe default via ${FEATURE_FLAG:-false}, and fails loudly on anything unrecognised. For numbers in shell, force base 10 with $((10#$MAX_CONN)) so a value like 08 does not trip Bash's octal interpretation of leading zeros.

Boolean token decision A decision on how to treat an incoming boolean-ish token: known true, known false, or invalid. Boolean Token Decision Incoming token in allowlist? true or 1 boolean true false, 0 or empty boolean false anything else exit with error
A boolean token maps to exactly one of three outcomes; the unrecognised branch fails loudly instead of defaulting.

Expected output

$ FEATURE_FLAG=false node -e 'console.log(require("./config/env").Env.FEATURE_FLAG)'
false
$ MAX_CONN=08 node -e 'console.log(require("./config/env").Env.MAX_CONN)'
8
$ RETRIES= node -e 'console.log(require("./config/env").Env.RETRIES)'
3

"false" becomes the boolean false, the number is parsed as an integer, and an empty RETRIES falls back to the real default of 3. Crucially, a configured RETRIES=0 now prints 0 rather than silently becoming 3, and a garbage value such as FEATURE_FLAG=maybe throws a Zod error naming the field before any request is served. The typed exports also give you editor autocompletion and compile-time checks: downstream code that treats Env.MAX_CONN as a number gets a real number, and a typo in the key name is a TypeScript error rather than an undefined at runtime.

Prevention

  1. Forbid raw process.env.X access outside the config module with an ESLint rule (no-process-env); import the validated object everywhere else. The rule turns "everyone must remember to use the config module" into a mechanically enforced invariant, so a new contributor cannot reintroduce the bug by reaching for process.env directly in a fresh file.
  2. Validate .env against the schema in CI so a bad value (FEATURE_FLAG=yes) fails the build, not production. A tiny CI step that imports the config module against a representative .env is enough — if parsing throws, the job fails with the offending field named.
  3. Document the accepted token set (true|false|1|0) in .env.example next to each boolean, and keep the example file in sync so the accepted vocabulary is discoverable without reading the schema source.

The measurable payoff is where the class of bug is caught. The chart below counts a representative quarter of coercion incidents by the stage that surfaced them, before and after adopting boundary validation: shifting detection left from production to CI and local startup is the entire goal.

Where coercion bugs are caught Bar chart of coercion incidents caught at local startup, CI, and production after adopting schema validation. Coercion Bugs Caught by Stage local startup 14 CI gate 6 production 1 Validation shifts detection left: most bugs die at startup, one leaks to prod.
After boundary validation, most coercion bugs are caught at local startup or in CI instead of production.

Platform caveats

WSL2: strip trailing \r from values written by Windows editors — MAX_CONN=10\r coerces to NaN and fails validation confusingly. A dos2unix .env in the bootstrap script, or a .gitattributes marking .env* as text eol=lf, removes the whole class of carriage-return coercion failures. macOS (Docker Desktop): a quoted empty value in Compose (RETRIES: "") is an empty string, not unset; the schema default only applies to truly absent keys, so prefer omitting the key. The same holds for env_file entries — a line RETRIES= sets the variable to empty, which is a distinct state from leaving it out. Apple Silicon (ARM64): no coercion behaviour differs by architecture, but if you run the config module under an x86 emulated Node alongside a native one, keep a single Node version pinned so the same Number() semantics apply everywhere; mismatched runtimes are a source of "works on my machine" parsing surprises.

Rollback

If new strict validation blocks a legitimately running service, relax the specific field to a permissive default while you correct the value, then re-tighten: change .positive() to .nonnegative() or widen the boolean enum temporarily. Do not revert to raw process.env access. Keep the schema in place and loosen one constraint at a time so you always know which field was the blocker, and open a follow-up to restore the tighter rule once the offending value is fixed at its source.

Frequently Asked Questions

Why is Boolean("false") true in JavaScript?

Because Boolean() tests truthiness, not content. Every non-empty string is truthy, so "false", "0", and "no" are all true; only "" is falsy. Environment variables are always strings, so Boolean(process.env.FLAG) can never give you a meaningful boolean. Parse the string against an explicit allowlist (true|false|1|0) and map to a real boolean instead.

What is the difference between an unset and an empty environment variable?

An unset variable is undefined in process.env; a present-but-empty one is the empty string "". They coerce differently — Number(undefined) is NaN while Number("") is 0 — and schema defaults typically apply only to truly absent keys. In Docker Compose, a line like RETRIES= or RETRIES: "" sets the variable to empty, which is not the same as omitting it, so prefer leaving the key out when you want the default.

Should I use Number(process.env.X) || default for numeric config?

No. Because 0 is falsy, that idiom replaces a legitimately configured 0 with the default, so you can never express values like zero retries or zero delay. It also masks the unset-versus-empty distinction. Use a schema with z.coerce.number().int().min(0).default(3) (or envalid's num) so the default applies only when the key is absent and 0 is preserved.

Why does PORT=08 or a leading-zero number misbehave?

In JavaScript, parseInt("08") returns 8, but parseInt also reads radix prefixes, so values like 0x1F parse unexpectedly and a supplied radix changes the result. In Bash, $((08)) throws a "value too great for base" error because a leading zero means octal. Coerce with Number() through a schema in JS, and force base 10 in shell with $((10#$PORT)).