Fixing Boolean and Number Env Coercion Bugs
DEBUG=false still enables debug mode and PORT=08 breaks parsing. Fix boolean truthiness, number coercion, and empty-vs-unset env bugs with Zod and envalid.
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.
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.
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.
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);
- 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 }),
});
- 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
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.
Prevention
- Forbid raw
process.env.Xaccess outside the config module with an ESLint rule (no-process-env); import the validated object everywhere else. - Validate
.envagainst the schema in CI so a bad value (FEATURE_FLAG=yes) fails the build, not production. - Document the accepted token set (
true|false|1|0) in.env.examplenext to each boolean.
WSL2: strip trailing
\rfrom values written by Windows editors —MAX_CONN=10\rcoerces toNaNand fails validation confusingly. 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.
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.