JavaScript's Array.reduce() Missing Initial Value Trap: The Discount That Silently Becomes NaN

2026-08-29

This function picks the best percent-off coupon from a customer's applicable coupons and applies it to a product. It works beautifully in tests where every product has multiple coupons — then production hits it with a single-coupon case and every price becomes NaN.

function applyBestDiscount(product, activeCoupons) {
  const applicable = activeCoupons.filter(c => c.appliesTo(product));

  const bestPercent = applicable.reduce(
    (best, coupon) => coupon.value > best ? coupon.value : best
  );

  return product.price * (1 - bestPercent / 100);
}

// Works:
applyBestDiscount(
  { price: 100 },
  [{ value: 10, appliesTo: () => true },
   { value: 25, appliesTo: () => true }]
);  // 75

// Silently broken:
applyBestDiscount(
  { price: 100 },
  [{ value: 25, appliesTo: () => true }]
);  // NaN

// Loudly broken:
applyBestDiscount({ price: 100 }, []);
// TypeError: Reduce of empty array with no initial value

The Bug

Array.prototype.reduce called without an initial value has two hidden behaviors that betray you at exactly the edges you forgot to test:

The single-element case is the poisonous one. With one coupon, reduce returns the entire coupon object, not its value field. Then:

product.price * (1 - {value: 25, ...} / 100)
// = 100 * (1 - NaN)
// = NaN

Dividing an object by 100 coerces it to NaN, which propagates through every subsequent arithmetic operation. Your checkout page shows $NaN, your metrics dashboard shows 0 revenue for those orders, and your alerts don't fire because no exception was ever thrown. The bug slips past unit tests that always use two or more coupons, and past code review because the reducer looks like it returns a number.

The same trap bites whenever your accumulator's type differs from your element's type — Math.max-style reductions returning wrong shapes, aggregations building {count, sum} from raw numbers, string concatenations that suddenly return an integer. Whenever accumulator ≠ element, the one-element case silently returns an element where an accumulator was expected.

The Fix

Always pass an initial value when reducing to a type that differs from the element type, or when the array might be empty:

function applyBestDiscount(product, activeCoupons) {
  const applicable = activeCoupons.filter(c => c.appliesTo(product));

  const bestPercent = applicable.reduce(
    (best, coupon) => coupon.value > best ? coupon.value : best,
    0  // seed: no discount if the array is empty or single-element
  );

  return product.price * (1 - bestPercent / 100);
}

With 0 as the seed, the callback runs once for each coupon and always compares a number to a number. Empty arrays return 0 — no discount, no exception. Single-element arrays run the callback exactly once and correctly return coupon.value.

Rule of thumb: skip the initial value only when the accumulator type equals the element type and the array is guaranteed non-empty. Otherwise — for aggregations, transformations, or anything user-supplied — always seed. It costs one extra argument and eliminates an entire category of shape-mismatch bugs that render as NaN instead of crashing.

Key Takeaway: When reduce runs without an initial value, a single-element array skips the callback entirely and returns the raw element — so any accumulator whose shape differs from the element type will silently produce garbage the moment your input thins out.

All newsletters