Fixing Boolean and Number Env Coercion Bugs
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.
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.
Resolution
- Coerce and validate with a schema so every variable arrives as the correct type. Zod's
coerceand 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.
- 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.
- 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.
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
- Forbid raw
process.env.Xaccess 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 forprocess.envdirectly in a fresh file. - Validate
.envagainst 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.envis enough — if parsing throws, the job fails with the offending field named. - Document the accepted token set (
true|false|1|0) in.env.examplenext 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.
Platform caveats
WSL2: strip trailing
\rfrom values written by Windows editors —MAX_CONN=10\rcoerces toNaNand fails validation confusingly. Ados2unix .envin the bootstrap script, or a.gitattributesmarking.env*astext 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 forenv_fileentries — a lineRETRIES=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 sameNumber()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)).