Configuration is the input your program receives before it receives any requests. It deserves the same suspicion as request bodies, and it usually gets far less. The environment is a flat map of strings, written by people editing YAML files, CI settings and Kubernetes manifests, and nothing checks its shape until your code reads it.
The interview version of this topic is usually a small code review: “here’s how this service reads PORT and ENABLE_CACHE, what’s wrong?” The answer is almost always “it trusts the type”.
Everything in process.env is a string
process.env looks like an object, but it’s special: every value is coerced to a string when you assign it, and every value you read is a string or undefined. Run on Node 22:
process.env.RETRIES = 3;
process.env.MISSING = undefined;
console.log('typeof RETRIES =', typeof process.env.RETRIES, '| MISSING =', JSON.stringify(process.env.MISSING));typeof RETRIES = string | MISSING = "undefined"That second line catches people in tests. Setting process.env.MISSING = undefined to “unset” a variable actually sets it to the nine-character string "undefined", which is truthy. To remove a variable, use delete process.env.MISSING.
Once you accept that the input is text, the usual conversions start to look dangerous:
console.log("Boolean('false') =", Boolean('false'));
console.log("Number('') =", Number(''), "| Number(' 80 ') =", Number(' 80 '), "| Number('0x1F90') =", Number('0x1F90'));
console.log("parseInt('8080abc') =", parseInt('8080abc', 10), "| parseInt('3e3') =", parseInt('3e3', 10), "| Number('3e3') =", Number('3e3'));Boolean('false') = true
Number('') = 0 | Number(' 80 ') = 80 | Number('0x1F90') = 8080
parseInt('8080abc') = 8080 | parseInt('3e3') = 3 | Number('3e3') = 3000Each line is a real bug waiting for the right typo:
Boolean(process.env.ENABLE_CACHE)istruefor"false","0"and"no", because any non-empty string is truthy. Someone setsENABLE_CACHE=falseto switch off a misbehaving cache during an incident and nothing happens.parseIntstops at the first non-digit, soPORT=8080abcquietly becomes 8080 andTIMEOUT_MS=3e3becomes 3. The second one is worse: the service starts, and every request times out after 3 milliseconds.Number('')is 0. An emptyPORT=line in a manifest, which a templating tool can produce when a variable is missing, becomes port 0, which tellslisten()to pick a random free port. The service starts fine and nobody can reach it.Numberaccepts hex, whitespace and exponent notation. That’s more permissive than most people expect from “a port number”.
Defaults that hide missing values
The classic default pattern has its own trap. With PORT set to the string "0":
process.env.PORT = '0';
console.log(JSON.stringify(process.env.PORT || 3000), Number(process.env.PORT) || 3000);"0" 3000The string "0" is truthy, so || keeps it. Convert first and || throws away a legitimate zero. ?? only falls back on undefined or null, which is closer to right for environment variables, but it keeps an empty string: PORT= gives you "", not your default.
The underlying question is what “missing” means for each setting. For most settings, “not set at all” should mean “use the default”, and “set to something I can’t parse” should mean “refuse to start”. Empty strings are the awkward middle. Pick a rule (treat empty as missing, or treat it as invalid) and apply it the same way everywhere, which is much easier when all parsing lives in one module.
One config module, validated at startup
Here’s a small configuration loader with no dependencies. It parses every variable it needs, collects all errors instead of stopping at the first, and returns a frozen object:
function port(name, raw, fallback) {
if (raw === undefined) return fallback;
if (!/^\d+$/.test(raw)) throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`);
const n = Number(raw);
if (n < 1 || n > 65535) throw new Error(`${name} must be between 1 and 65535, got ${n}`);
return n;
}
function flag(name, raw, fallback) {
if (raw === undefined) return fallback;
const v = raw.trim().toLowerCase();
if (['true', '1', 'yes'].includes(v)) return true;
if (['false', '0', 'no'].includes(v)) return false;
throw new Error(`${name} must be true/false, got ${JSON.stringify(raw)}`);
}
function required(name, raw) {
if (raw === undefined || raw === '') throw new Error(`${name} is required`);
return raw;
}
export function loadConfig(env = process.env) {
const errors = [];
const read = (fn) => { try { return fn(); } catch (e) { errors.push(e.message); } };
const config = {
port: read(() => port('PORT', env.PORT, 3000)),
newCheckout: read(() => flag('FEATURE_NEW_CHECKOUT', env.FEATURE_NEW_CHECKOUT, false)),
databaseUrl: read(() => required('DATABASE_URL', env.DATABASE_URL)),
};
if (errors.length) throw new Error(`Invalid configuration:\n - ${errors.join('\n - ')}`);
return Object.freeze(config);
}The entry point calls it before creating the server, prints the error and exits with status 1. Running it with different environments:
$ DATABASE_URL=postgres://app:s3cret@db:5432/app node config.mjs
starting with { port: 3000, newCheckout: false, databaseUrl: 'db:5432' }
$ PORT=8080abc FEATURE_NEW_CHECKOUT=ture node config.mjs
Invalid configuration:
- PORT must be an integer, got "8080abc"
- FEATURE_NEW_CHECKOUT must be true/false, got "ture"
- DATABASE_URL is required
$ PORT=70000 FEATURE_NEW_CHECKOUT=FALSE DATABASE_URL=postgres://a:b@h/d node config.mjs
Invalid configuration:
- PORT must be between 1 and 65535, got 70000A few decisions are worth defending in an interview:
Fail at startup, not on first use. If DATABASE_URL is only read inside a request handler, a bad deploy passes its health checks and starts failing customer requests. Validating at boot makes the process crash before it receives traffic, so the orchestrator holds the rollout and the old version keeps serving. A non-zero exit code is what tells Kubernetes or systemd that something is wrong.
Report every error at once. Fixing one variable, redeploying and discovering the next one is slow. Three errors in one message cost one deploy.
Pass env as a parameter. loadConfig(env = process.env) lets tests call loadConfig({ PORT: 'abc' }) without mutating the global environment, which avoids tests leaking state into each other.
Read once, then freeze. The rest of the code imports config.port, a number. Nobody else touches process.env, so there’s one place where the string-to-type rules live.
Never print secrets. The happy-path log prints only the database host. The error messages include the raw value for PORT and the flag, which is helpful, but a loader that echoed DATABASE_URL would put a password in your logs. If a value might be sensitive, report that it’s invalid without repeating it. Libraries such as zod or envalid give you the same structure with less code; the principle is identical.
.env files in Node 22
Locally, people keep variables in a .env file. Node 20.6 added native support, so you no longer need the dotenv package for the basic case:
$ cat .env
PORT=8080
DATABASE_URL="postgres://app:s3cret@db:5432/app"
FEATURE_NEW_CHECKOUT=true
$ node --env-file=.env -e "console.log(process.env.PORT, process.env.FEATURE_NEW_CHECKOUT)"
8080 true
$ PORT=9000 node --env-file=.env -e "console.log('PORT with shell override:', process.env.PORT)"
PORT with shell override: 9000Variables already in the environment win over the file, which is the behaviour you want: a deployment can override a developer default without editing the file. A missing file is a hard error (node: missing.env: not found, exit code 9); --env-file-if-exists makes it optional. From code, process.loadEnvFile('.env') does the same thing, and util.parseEnv() parses the format into a plain object without touching process.env.
Keep .env out of version control and commit a .env.example with placeholder values. In production, the variables should come from the platform (Kubernetes secrets, your cloud’s secret manager, CI variables), not from a file baked into the image.
Interview practice
A service reads const cacheEnabled = Boolean(process.env.CACHE_ENABLED) and const ttl = parseInt(process.env.CACHE_TTL_SECONDS) || 60. During an incident, an engineer sets CACHE_ENABLED=false and CACHE_TTL_SECONDS=0 to turn caching off. What happens, and how would you rewrite it?
Answer
Neither change works. Boolean('false') is true, so caching stays on. parseInt('0') is 0, and 0 || 60 is 60, so the TTL goes back to the default. The engineer has changed two settings, restarted the service, and nothing about its behaviour changed, which during an incident is about the worst outcome possible.
I’d move both into a startup config module. CACHE_ENABLED accepts an explicit list of true and false spellings and rejects anything else, so a typo like flase stops the process instead of being silently treated as true. CACHE_TTL_SECONDS must match ^\d+$, uses undefined (not falsiness) to decide whether to apply the default, and accepts 0 as a real value if zero is meaningful, or rejects it with a clear error if it isn’t. I’d add unit tests that call the loader with 'false', '0', '' and an unset variable, because those four inputs are where the original code went wrong.