Skip to content

Equality, Coercion, and Absence

Configuration code often has to distinguish between a missing value and a deliberately empty one. This looks harmless:

js
const retryCount = options.retryCount || 3;

But a caller that explicitly requests zero retries receives three. The bug comes from using JavaScript's truthiness rules to answer a more precise question: whether a value is absent.

JavaScript's conversion rules are not random, but several operators perform conversions that are easy to overlook in compact code. The goal is not to memorize novelty puzzles such as [] == ![]. It is to choose operations that express the program's actual question.

Strict equality is the ordinary default

Strict equality compares values without first converting them to another type:

js
42 === 42; // true
42 === "42"; // false
false === 0; // false
null === undefined; // false

Use === and !== for ordinary comparisons. This keeps a type mismatch visible rather than allowing an operator to reinterpret it.

For primitives of the same type, strict equality generally compares their values. Objects compare by identity:

js
const first = { id: 1 };
const second = { id: 1 };
const alias = first;

first === second; // false
first === alias; // true

The same applies to arrays and functions:

js
[1, 2] === [1, 2]; // false
(() => {}) === (() => {}); // false

Each literal creates a new reference value. Structural equality is a domain operation: you must decide which fields matter, whether order matters, how dates and maps compare, and what to do with cycles. Converting objects to JSON strings is not a safe general equality algorithm.

Object.is differs at two numeric edges

Object.is uses a sameness relation that differs from === for NaN and signed zero:

js
NaN === NaN; // false
Object.is(NaN, NaN); // true

0 === -0; // true
Object.is(0, -0); // false

Most code should not replace strict equality wholesale with Object.is. Use Number.isNaN to detect NaN, and use Object.is when its exact sameness semantics are relevant. React and some other libraries use Object.is internally when deciding whether values changed, which is one reason the distinction occasionally appears in application behavior.

Loose equality converts before comparing

The == operator applies an abstract coercion algorithm:

js
42 == "42"; // true
false == 0; // true
"" == 0; // true

Those results can make heterogeneous external data appear compatible before it has actually been validated. Avoid using loose equality as a convenient parser:

js
// Too permissive for validating a numeric API field.
if (input.count == 10) {
  // "10" also enters this branch.
}

Convert or validate explicitly instead:

js
if (typeof input.count === "number" && input.count === 10) {
  // The type and value are both part of the condition.
}

There is one recognizable idiom with intentional semantics:

js
if (value == null) {
  // true only for null or undefined
}

Because the loose-equality algorithm treats null and undefined as equal to each other but not to other falsy values, this is a compact nullish check. Some codebases allow it; others prefer the explicit form:

js
if (value === null || value === undefined) {
  // missing
}

Either can be deliberate. The important distinction is between using one known coercion rule and relying on loose equality generally.

Conditions use truthiness

An if condition converts its expression to a boolean. These values are falsy:

text
false
0
-0
0n
""
null
undefined
NaN

Everything else is truthy, including empty arrays and empty objects:

js
Boolean([]); // true
Boolean({}); // true
Boolean("false"); // true

The last example is important at configuration boundaries. A non-empty string is truthy regardless of what word it contains:

js
const enabled = Boolean(process.env.FEATURE_ENABLED);

If the environment variable is the string "false", enabled becomes true. A boolean parser needs an explicit contract:

js
function parseBoolean(value) {
  if (value === "true") return { ok: true, value: true };
  if (value === "false") return { ok: true, value: false };
  return { ok: false, error: "Expected 'true' or 'false'" };
}

Truthiness is appropriate when the domain question is truthiness:

js
if (selectedUser) {
  renderProfile(selectedUser);
}

It is less appropriate when several falsy values mean different things:

js
if (!quantity) {
  // Is quantity missing, zero, NaN, or the wrong type?
}

At a boundary, ask the precise questions:

js
if (quantity === undefined) {
  return { ok: false, error: "quantity is required" };
}

if (typeof quantity !== "number" || !Number.isFinite(quantity)) {
  return { ok: false, error: "quantity must be a finite number" };
}

if (quantity < 0) {
  return { ok: false, error: "quantity cannot be negative" };
}

&& and || return operands

Logical operators short-circuit and return one of their original operands. They do not necessarily return booleans:

js
"ready" && 42; // 42
"" && 42; // ""

"custom" || "default"; // "custom"
"" || "default"; // "default"

For a && b:

  • If a is falsy, the result is a and b is not evaluated.
  • Otherwise, the result is b.

For a || b:

  • If a is truthy, the result is a and b is not evaluated.
  • Otherwise, the result is b.

Short-circuiting is useful for guarded work:

js
debugEnabled && logDetails();

An if statement is often clearer when the call is performed for its side effect:

js
if (debugEnabled) {
  logDetails();
}

Logical expressions are most readable when their result is used as a value.

?? answers a narrower question than ||

Nullish coalescing returns its right operand only when the left operand is null or undefined:

js
0 ?? 10; // 0
false ?? true; // false
"" ?? "Untitled"; // ""
null ?? "Untitled"; // "Untitled"
undefined ?? "Untitled"; // "Untitled"

Compare logical OR:

js
0 || 10; // 10
false || true; // true
"" || "Untitled"; // "Untitled"

Choose based on the domain:

js
// Zero retries is valid, so only absence receives a default.
const retryCount = options.retryCount ?? 3;

// An empty or whitespace-only label is not useful in this UI.
const label = options.label?.trim() || "Untitled";

The second example intentionally uses truthiness after trimming. The expression communicates that an empty result should fall back. ?? would preserve it.

JavaScript also has nullish assignment:

js
settings.theme ??= "system";

This mutates settings.theme only if it is nullish. It can be convenient for local mutable state, but avoid mutating caller-owned configuration merely to apply defaults. Constructing a normalized value makes ownership clearer:

js
const normalized = {
  theme: settings.theme ?? "system",
  retryCount: settings.retryCount ?? 3,
};

Optional chaining short-circuits nullish access

Optional chaining pairs naturally with nullish coalescing:

js
const city = user.profile?.address?.city ?? "Unknown";

Evaluation stops at the first null or undefined before an optional access, yielding undefined. It does not stop for other falsy values:

js
const data = { count: 0 };
data.count?.toString(); // "0"

That is usually exactly what data access needs.

Be careful about where the optional operator appears:

js
user.profile?.getName();

This handles a missing profile, but if profile exists without a callable getName, it throws. To make the method optional as well:

js
user.profile?.getName?.();

Optional chaining is not a substitute for a data contract. If profile.address.city is required for a particular operation, silently turning its absence into undefined may defer an error until it is harder to diagnose.

Conversion should be visible at boundaries

JavaScript provides explicit conversion functions:

js
String(42); // "42"
Number("42"); // 42
Boolean(0); // false

Explicit does not mean safe without validation:

js
Number(""); // 0
Number("   "); // 0
Number("twelve"); // NaN

If an empty form field should be missing rather than zero, check that before converting:

js
function parseOptionalNumber(text) {
  const trimmed = text.trim();
  if (trimmed === "") return { ok: true, value: undefined };

  const value = Number(trimmed);
  if (!Number.isFinite(value)) {
    return { ok: false, error: "Expected a finite number" };
  }

  return { ok: true, value };
}

parseInt and parseFloat parse numeric prefixes, which is useful for some text formats but too permissive for others:

js
Number("12px"); // NaN
Number.parseInt("12px", 10); // 12

Use the operation whose accepted input matches your contract.

String conversion can also invoke object hooks and produce generic results:

js
String({ id: 1 }); // "[object Object]"

For user-facing output, format domain values intentionally. For diagnostic output, structured logging or JSON.stringify may be useful, with the understanding that JSON omits or rejects some JavaScript values and cannot represent cycles.

typeof is useful but coarse

typeof works well for most primitives and functions:

js
typeof "hello"; // "string"
typeof 42; // "number"
typeof 42n; // "bigint"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof Symbol(); // "symbol"
typeof (() => {}); // "function"

Objects are deliberately broad:

js
typeof {}; // "object"
typeof []; // "object"
typeof new Date(); // "object"
typeof null; // "object"

typeof null === "object" is a historical language behavior. Use explicit checks appropriate to the expected value:

js
value === null;
Array.isArray(value);
value instanceof Date;

Cross-realm values can complicate instanceof in browser code, and custom classes can customize some behavior, so validation should be designed for the actual boundary rather than assembled from one universal classifier.

Missing properties and explicit undefined

These objects produce the same value through property access:

js
const missing = {};
const explicit = { theme: undefined };

missing.theme; // undefined
explicit.theme; // undefined

But they do not have the same shape:

js
Object.hasOwn(missing, "theme"); // false
Object.hasOwn(explicit, "theme"); // true

Usually, treating both as absent is convenient. Sometimes an update API uses presence to mean “the caller addressed this field,” with undefined carrying separate meaning. Define that contract explicitly.

Default parameters also activate for undefined, not null:

js
function greet(name = "Guest") {
  return `Hello, ${name}`;
}

greet(); // "Hello, Guest"
greet(undefined); // "Hello, Guest"
greet(null); // "Hello, null"

Destructuring defaults follow the same undefined rule:

js
const { theme = "system" } = { theme: null };
console.log(theme); // null

If both null and undefined should use a default, apply ?? after reading the value.

Boundary code should normalize once

Scattered coercion makes the rest of a program repeatedly ask what values mean. A better pattern is to validate and normalize at the boundary:

js
function normalizeOptions(input) {
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
    return {
      ok: false,
      errors: ["options must be an object"],
    };
  }

  const retryCount = input.retryCount ?? 3;
  const verbose = input.verbose ?? false;

  const errors = [];

  if (!Number.isInteger(retryCount) || retryCount < 0) {
    errors.push("retryCount must be a non-negative integer");
  }

  if (typeof verbose !== "boolean") {
    errors.push("verbose must be a boolean");
  }

  if (errors.length > 0) {
    return { ok: false, errors };
  }

  return {
    ok: true,
    value: { retryCount, verbose },
  };
}

After success, the rest of the program works with a stable internal shape. It no longer needs to wonder whether retryCount is a numeric string, null, NaN, or missing.

This separation remains essential after TypeScript is introduced. A static type annotation can describe the result of successful normalization, but it cannot force JSON, environment variables, or network responses to obey that description.

The next article expands from individual values to the data structures used to organize them: objects, arrays, maps, and sets. Identity, ownership, copying, and mutation will continue to matter.

Further reference