~/blog

Every process.env value is a string, so DEBUG=false is truthy

published

#node#javascript#config

TL;DR

process.env is not a plain object with the values you put in it. Every value is coerced to a string on the way in and on the way out, so DEBUG=false gives you "false" — a five-character string, which is truthy. if (process.env.DEBUG) is true for false, for 0, and for no. The only falsy value the environment can hand you is the empty string. Parse the value; never test it for truthiness.

The problem

The flag reads like a boolean, so it gets used like one:

if (process.env.DEBUG) {
  enableVerboseLogging()
}

Someone turns it off the obvious way, and it stays on:

$ DEBUG=false node -e "console.log(typeof process.env.DEBUG, JSON.stringify(process.env.DEBUG), Boolean(process.env.DEBUG))"
string "false" true

0 behaves the same way, which is worse, because 0 is the one value people are confident about:

$ FLAG=0 node -e "console.log(JSON.stringify(process.env.FLAG), Boolean(process.env.FLAG), Number(process.env.FLAG))"
"0" true 0

The string "0" is truthy. Number("0") is 0 and falsy. So whether the flag works depends entirely on whether the reader remembered to convert it, and both versions look correct in review.

Why it happens

process.env is backed by the operating system’s environment block, which stores bytes, not JavaScript values. Node exposes it through an object whose setter stringifies whatever you assign. That coercion is not a convenience — it is the only representation the underlying storage has.

You can watch it happen to four different types at once:

$ node -e "process.env.A=false; process.env.B=0; process.env.C=undefined; process.env.D=null; console.log(JSON.stringify([process.env.A,process.env.B,process.env.C,process.env.D]))"
["false","0","undefined","null"]

The third one is the trap that costs real time. Assigning undefined does not unset the variable and does not give you undefined back — it gives you the string "undefined", which is truthy and seven characters long. Code that clears a flag conditionally sets it instead:

process.env.API_KEY = config.apiKey   // config.apiKey is undefined
if (process.env.API_KEY) {            // true — the string "undefined"
  authenticate(process.env.API_KEY)   // sends the literal text "undefined"
}

To actually remove a variable you have to delete the key:

$ node -e "process.env.X='1'; process.env.X=undefined; console.log(JSON.stringify(process.env.X)); delete process.env.X; console.log(JSON.stringify(process.env.X))"
"undefined"
undefined

There is exactly one falsy string the environment can produce, and it is the empty one. Note that an unset variable and an empty one are distinguishable, which is the hook every correct parser hangs on:

$ EMPTY= node -e "console.log(JSON.stringify(process.env.EMPTY), JSON.stringify(process.env.NOPE), Boolean(''))"
"" undefined false
Shell inputprocess.env.XTruthy?Number(x)
X=false"false"yesNaN
X=0"0"yes0
X=no"no"yesNaN
X=""no0
(unset)undefinednoNaN

Four of those five are values a person would write to mean “off”. One of them works.

What to do

Parse the string into the type you actually want, in one place, and use the parsed value everywhere else.

/** Accepts the spellings people actually type; everything else is an error. */
function envFlag(name: string, fallback = false): boolean {
  const raw = process.env[name]
  if (raw === undefined || raw === '') return fallback
  const v = raw.trim().toLowerCase()
  if (['1', 'true', 'yes', 'on'].includes(v)) return true
  if (['0', 'false', 'no', 'off'].includes(v)) return false
  throw new Error(`${name} must be a boolean, got ${JSON.stringify(raw)}`)
}

const DEBUG = envFlag('DEBUG')

Three details in that function are load-bearing:

Numbers need the same treatment, and parseInt is the wrong tool because it stops at the first non-digit — parseInt('3000abc') is 3000, and parseInt('') is NaN:

function envInt(name: string, fallback: number): number {
  const raw = process.env[name]
  if (raw === undefined || raw.trim() === '') return fallback
  const n = Number(raw)
  if (!Number.isInteger(n)) throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`)
  return n
}

Number rejects '3000abc' with NaN instead of quietly truncating it, which is what you want for a port number.

To unset a variable, delete the key rather than assigning:

delete process.env.API_KEY   // correct
process.env.API_KEY = undefined  // sets it to the string "undefined"

Caveats

References