bastianplsfix

Truthiness

Put any value where JavaScript wants a condition and it gets converted to true or false. Exactly eight values convert to false: false, 0, -0, 0n, "", null, undefined, and NaN. Everything else is truthy. Everything: every object, every array, every function, "0", "false", -1, Infinity, and a single space. The list is short enough to memorize, and memorizing it is the skill this page is after.

One thing to know before the first step: the conversion happens in if, while, for, the conditional operator, &&, ||, !, and Boolean(x). It does not happen in ===, and it does not happen in ??.

Create programs/truthiness.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assert, assertEquals, assertFalse } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

exactly eight values are falsy

Boolean(x) performs the conversion directly, so the whole falsy list fits in one step. Two assertion helpers carry it: assert passes when handed true, and assertFalse passes when handed false.

Deno.test("exactly eight values are falsy", () => {
assertFalse(Boolean(false));
assertFalse(Boolean(0));
assertFalse(Boolean(-0));
assertFalse(Boolean(0n));
assertFalse(Boolean(""));
assertFalse(Boolean(null));
assertFalse(Boolean(undefined));
assertFalse(Boolean(NaN));
});
Check programs/truthiness.test.ts
running 1 test from ./programs/truthiness.test.ts
exactly eight values are falsy ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

Eight values, and the list has more structure than it first appears. Group them:

  1. 0, -0, and 0n are the numeric zeros in their three flavors: the number zero, the signed zero the equality page met in === calls the two zeros the same value, and 0n, which is zero as a bigint.
  2. null and undefined are the two ways of saying nothing, and the whole nothing, twice page is about telling them apart.
  3. NaN is arithmetic's failure value, from the equality page's NaN is never equal to anything, including itself.
  4. That leaves false itself and the empty string "".

Nothing else in the language is falsy. Hold onto that sentence, because the next step is built on it.

everything else is truthy, even empty containers

If you arrive from Python or Ruby, where an empty collection is falsy, this is the habit to unlearn first. Predict what Boolean says about an empty array, then save:

Deno.test("everything else is truthy, even empty containers", () => {
assertEquals(Boolean([]), false);
});
Check programs/truthiness.test.ts
running 2 tests from ./programs/truthiness.test.ts
exactly eight values are falsy ... ok (0ms)
everything else is truthy, even empty containers ... FAILED (8ms)

ERRORS

everything else is truthy, even empty containers => ./programs/truthiness.test.ts:15:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- true
+ false

FAILURES

everything else is truthy, even empty containers => ./programs/truthiness.test.ts:15:11

FAILED | 1 passed | 1 failed (10ms)

error: Test failed

An empty array is truthy. The rule underneath is simpler than any list: every object is truthy, because an object is something, and emptiness is not a property the conversion looks at. [] is an object with nothing in it, but the conversion never opened it to check.

Correct the prediction, and collect the values most likely to surprise:

Deno.test("everything else is truthy, even empty containers", () => {
assert(Boolean([]));
assert(Boolean({}));
assert(Boolean(new Map()));
assert(Boolean("0"));
assert(Boolean("false"));
assert(Boolean(" "));
assert(Boolean(-1));
assert(Boolean(Infinity));
});
everything else is truthy, even empty containers ... ok (0ms)

Walk the surprises.

  1. [], {}, and new Map() are empty containers, and all three are objects, so all three are truthy.
  2. "0" and "false" are non-empty strings. The conversion asks whether the string has characters, never what the characters spell, so the text zero and the text false are both truthy.
  3. " " is a single space, which is a character, so the string is not empty and the value is truthy.
  4. -1 and Infinity are numbers that are not zero and not NaN, so they are truthy, negative or not, finite or not.

The eight falsy values are the complete list. Any value not on it converts to true, with no exceptions to discover later.

emptiness is a question you have to ask

The empty-array surprise has a practical edge, so it earns its own claim. A condition on an array cannot detect emptiness; only a question about length can.

Deno.test("emptiness is a question you have to ask", () => {
const lines: string[] = [];
assert(Boolean(lines));
assertEquals(lines.length, 0);
assertFalse(lines.length > 0);
});
emptiness is a question you have to ask ... ok (0ms)

Read the three assertions as one argument.

  1. Boolean(lines) is true even though lines holds nothing, so if (lines) answers "do I have an array?" and never "does it hold anything?".
  2. lines.length is 0, which is the fact the condition was probably after.
  3. lines.length > 0 is false, and a comparison like this one produces a genuine boolean that says what it means.

Write lines.length === 0 for empty and lines.length > 0 for non-empty. The same goes for objects: ask about Object.keys(o).length, or better, model the absence with a type instead of an empty object, the way an optional property means undefined, not null does on the nothing-twice page.

&& and || return operands, not booleans

The logical operators use the same conversion, but they do something subtler with it: they return one of their operands, unconverted. || gives the first truthy operand, or the last one if none are truthy. && gives the first falsy operand, or the last one if all are truthy. Predict all four lines:

Deno.test("&& and || return operands, not booleans", () => {
const discount: number | undefined = 0;
const label: string | undefined = "a";
assertEquals(discount || "fallback", "fallback");
assertEquals(label && "b", "b");
assertEquals(discount && "b", 0);
assertEquals(typeof (discount && "b"), "number");
});
&& and || return operands, not booleans ... ok (0ms)

Walk the lines.

  1. discount || "fallback" finds discount falsy, so it moves on and returns "fallback". The zero is discarded, which is the || trap the nothing-twice page measured in ?? treats only null and undefined as missing.
  2. label && "b" finds label truthy, so it keeps going and returns the last operand, "b".
  3. discount && "b" finds discount falsy and stops there, returning the 0 itself. Not false: the actual zero.
  4. typeof (discount && "b") is "number", proving no boolean was ever produced. The operators route values; they do not convert them.

Line 3 is the bug that ships constantly. A guard written with && passes its falsy operand downstream, so rendering count && badge puts a literal 0 on the page whenever the count is zero. The guard did not suppress the value; the guard is the value.

Both operators also short-circuit: the right side is never evaluated when the left side decides the answer. That is a feature to rely on, and the next step proves it with a witness.

the conditional operator runs only the branch it picks

condition ? a : b is the expression form of if, and its condition is converted exactly the same way. The step also plants a witness, ran, to record which branches actually execute:

Deno.test("the conditional operator runs only the branch it picks", () => {
const discount: number | undefined = 0;
assertEquals(discount ? "some" : "none", "none");

const ran: string[] = [];
function mark(name: string) {
ran.push(name);
return name;
}
const price: number | undefined = 10;
const chosen = price ? mark("then") : mark("else");
assertEquals(chosen, "then");
assertEquals(ran, ["then"]);
});
the conditional operator runs only the branch it picks ... ok (0ms)

Two facts, one step.

  1. discount ? "some" : "none" answers "none", because the zero converts to false here exactly as it would in an if. Same question, expression form.
  2. chosen is "then", and ran holds only ["then"]. The call mark("else") sits right there in the source and never executed, because the operator evaluates only the branch it picks.

That second fact makes it safe to put real work in both branches, and makes the conditional operator the tool for choosing between two expressions. While we are counting conversions: x ? true : false, !!x, and Boolean(x) all produce the same boolean. Boolean(x) says so most clearly, and the linter section at the bottom has an opinion about !!.

a truthiness check cannot tell missing from falsy

The shortcut if (settings.retries) reads as "was a value supplied?", and that is not the question it asks. Build the two cases that expose the difference:

Deno.test("a truthiness check cannot tell missing from falsy", () => {
type Settings = { retries?: number };
const absent: Settings = {};
const zero: Settings = { retries: 0 };
assertFalse(Boolean(absent.retries));
assertFalse(Boolean(zero.retries));
assertFalse("retries" in absent);
assert("retries" in zero);
assertEquals(absent.retries ?? 3, 3);
assertEquals(zero.retries ?? 3, 0);
});
a truthiness check cannot tell missing from falsy ... ok (0ms)

Follow the pairs.

  1. Boolean(absent.retries) and Boolean(zero.retries) are both false, for different reasons: the first read produced undefined, the second produced 0, and both are on the falsy list. A truthiness check sends both into the same branch, so a configured zero silently becomes a default.
  2. "retries" in absent is false and "retries" in zero is true. The in operator asks whether the key exists, the question from in sees the property that reads cannot on the nothing-twice page, and it tells the two objects apart.
  3. absent.retries ?? 3 is 3 and zero.retries ?? 3 is 0. ?? asks only about null and undefined, so the missing value falls back and the zero survives.

Six of the eight falsy values are real data. A zero price, an empty note, and a false setting are answers, not absences. Whenever the difference between "not set" and "set to something falsy" matters, and for options, counters, and prices it nearly always does, reach for in or ?? instead of a truthiness check.

a wrapper object is truthy even around false

The values-and-references page warned about wrapper objects in a wrapper object is not its primitive. Truthiness gives the shortest proof of why they are poison:

Deno.test("a wrapper object is truthy even around false", () => {
assertFalse(Boolean(false));
assert(Boolean(new Boolean(false)));
assertEquals(typeof new Boolean(false), "object");
});
a wrapper object is truthy even around false ... ok (0ms)

Line by line:

  1. false is falsy. It is the first entry on the list.
  2. new Boolean(false) is truthy. It is an object that wraps false, and every object is truthy, so a condition holding this value takes the true branch while the value inside it says false.
  3. typeof confirms which kind of value it is: "object".

"Every object is truthy" has no exceptions, not even an object whose entire purpose is to contain false. Never write the wrapper constructors.

a truthiness check narrows, but not past empty text

TypeScript watches truthiness checks and narrows types across them, which makes a condition a real tool for getting rid of undefined. It is worth knowing exactly how far the narrowing goes:

Deno.test("a truthiness check narrows, but not past empty text", () => {
function show(note: string | undefined): number {
if (note) {
return note.length;
}
return 0;
}
assertEquals(show("oak"), 3);
assertEquals(show(undefined), 0);
assertEquals(show(""), 0);
});
a truthiness check narrows, but not past empty text ... ok (0ms)

Trace the three calls.

  1. show("oak") enters the branch, where TypeScript has narrowed note from string | undefined to string, so .length is legal and the answer is 3.
  2. show(undefined) fails the condition, takes the return 0 path, and never touches .length. This is the narrowing doing its job.
  3. show("") also returns 0, and this line is the one to study. The empty string is falsy, so it failed the condition at run time. But inside the branch the type is string, and string still includes "". The check filtered out more values than the type records, and the checker has no way to say so.

So a truthiness check removes undefined from the type and the empty string from the runtime, and only the first of those is written down. When non-empty is the actual requirement, check note.length > 0 and say what you mean.

the checker catches a function used as a condition

TypeScript refuses a few conditions it can already answer. The classic is a forgotten pair of parentheses. Type this version exactly, missing parentheses and all, and save:

Deno.test("the checker catches a function used as a condition", () => {
function isReady() {
return false;
}
let state = "not ready";
if (isReady) {
state = "ready";
}
assertEquals(state, "not ready");
});
Check programs/truthiness.test.ts
TS2774 [ERROR]: This condition will always return true since this function is always defined. Did you mean to call it instead?
if (isReady) {
~~~~~~~
at file:///programs/truthiness.test.ts:92:9

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Read the diagnostic through this page's model.

  1. isReady without parentheses is the function itself, not its return value.
  2. A function is an object, and every object is truthy, so the condition can only ever pass, whatever the function would have returned.
  3. The checker knows both facts, concludes the if is not asking a real question, and stops the program before the bug ships. The hint even names the fix.

Add the parentheses so the condition asks the function's answer instead of checking its existence:

Deno.test("the checker catches a function used as a condition", () => {
function isReady() {
return false;
}
let state = "not ready";
if (isReady()) {
state = "ready";
}
assertEquals(state, "not ready");
});
the checker catches a function used as a condition ... ok (0ms)

isReady() returns false, the branch is skipped, and state stays "not ready". The checker rejects a few other always-truthy conditions the same way, such as a string literal on the left of &&, which is why the examples on this page route values through union-typed variables. But know the limit: if (items) on an array passes the checker without comment, because an array really is always truthy and nothing about that is a type error. The empty-array trap from emptiness is a question you have to ask is yours to catch, not the checker's.

Ask the linter

Deno's linter has no rule that requires genuine booleans in conditions, so nothing warns about if (items) either. What it does ship, on by default, is no-extra-boolean-cast, which flags a conversion in a place that already converts:

error[no-extra-boolean-cast]: Redundant double negation.
--> programs/truthiness.test.ts:2:7
|
2 | if (!!note) {
| ^^^^^^
= hint: Remove the double negation (`!!`), it is unnecessary

docs: https://docs.deno.com/lint/rules/no-extra-boolean-cast

The if was going to convert note anyway, so !! adds nothing but noise. Write if (note), and keep Boolean(x) for the places where you actually want a boolean value to store or return.

In practice