Skip to content
JavaScript

Filtering in JavaScript: filter() Without Surprises

By The EbookWale Team · Updated August 27, 2026 · 18 min read

JavaScript filter() returns a new array of elements that pass a predicate. Learn its syntax, truthiness traps, async bug, and safer patterns.

JavaScript filter() selects zero or more existing array elements with a predicate and returns them in a new array. A predicate is a function whose result is interpreted as true or false. In practice, filtering JS arrays becomes predictable once truthiness, callback arguments, shallow copies, and asynchronous tests are understood.

JavaScript filter() returns all elements whose predicate results are truthy, while preserving their relative order in a new shallow array.

JavaScript Filtering in 60 Seconds

Call filter() on an array and pass it a predicate. JavaScript invokes that predicate for each existing element. A truthy result retains the element; a falsy result excludes it.

Source arrayp1 ·$12p2 ·$35p3 · $8p4 ·$60p5 ·$25Is price ≤ $25?truthy: retainfalsy: excludenew array[ p1, p3, p5 ]left outp2, p4
Each value is tested once; passing values keep their order in the new array.
const result = array.filter((element, index, array) => {
  return condition;
}, optionalThisArg);

The callback can receive:

  • element: the value currently being tested
  • index: that value’s numeric index
  • array: the object being traversed

The optional thisArg becomes this inside a non-arrow callback. Most filtering does not need it.

Here is a complete first example:

const products = [
  { id: "p1", price: 12 },
  { id: "p2", price: 35 },
  { id: "p3", price: 8 },
  { id: "p4", price: 60 },
  { id: "p5", price: 25 },
];

const affordableProducts = products.filter((product) => product.price <= 25);

console.log(affordableProducts.map((product) => product.id).join(","));
console.log(products.map((product) => product.id).join(","));
p1,p3,p5
p1,p2,p3,p4,p5

The predicate product.price <= 25 returns a Boolean. Three products pass, so affordableProducts contains three elements. The source array remains in its original order and still contains all five products.

Filtering selects existing values. It does not transform them. If the required result changes every value, use map(). If it selects values and then changes them, use filter() followed by map(). The broader JavaScript array methods guide compares those operations in context.

Same source values[ 12, 35, 8 ]filter(x ≤ 20)35 leaves[ 12, 8 ]map(to dollars)all three change[ $12, $35, $8 ]fewer valuessame count
Filter changes membership; map changes representation.
const products = [
  { id: "p1", price: 12 },
  { id: "p2", price: 35 },
  { id: "p3", price: 8 },
  { id: "p4", price: 60 },
  { id: "p5", price: 25 },
];

const affordableLabels = products
  .filter((product) => product.price <= 25)
  .map((product) => `$${product.price}`);

console.log(JSON.stringify(affordableLabels));
["$12","$8","$25"]

How filter() Actually Builds the Result

The callback receives the current element, its index, and the array being traversed. This standalone example uses all three:

const products = [
  { id: "p1", price: 12 },
  { id: "p2", price: 35 },
  { id: "p3", price: 8 },
  { id: "p4", price: 60 },
  { id: "p5", price: 25 },
];

const unusualProducts = products.filter((product, index, source) => {
  const previousProduct = source[index - 1];
  return index > 0 && product.price > previousProduct.price;
});

console.log(unusualProducts.map((product) => product.id).join(","));
p2,p4

The callback keeps p2 because its price is greater than the preceding product’s price. It keeps p4 for the same reason.

JavaScript interprets the callback result through truthiness. Values such as true, nonzero numbers, nonempty strings, objects, arrays, and Promises are truthy. Values including false, 0, -0, 0n, "", null, undefined, and NaN are falsy.

A predicate should usually return an explicit Boolean condition:

const products = [
  { name: "Keyboard", stock: 3 },
  { name: "Mouse", stock: 0 },
  { name: "Monitor", stock: 2 },
];

const available = products.filter((product) => product.stock > 0);

console.log(available.map((product) => product.name).join(", "));
Keyboard, Monitor

filter() returns a new outer array. When the retained values are objects, however, both arrays refer to the same objects. This is a shallow copy, not a deep copy.

const products = [
  { name: "Keyboard", price: 120 },
  { name: "Mouse", price: 40 },
];

const premiumProducts = products.filter((product) => product.price >= 100);

console.log(premiumProducts !== products);
console.log(premiumProducts[0] === products[0]);

premiumProducts[0].price = 99;
console.log(products[0].price);
true
true
99

The result array is separate, but the retained Keyboard object is shared. Replacing an entry in premiumProducts would change only that array. Editing a property on the shared object is visible through both arrays.

Matches retain their original relative order. If nothing passes, the result is an empty array:

const products = [
  { name: "Keyboard", stock: 3 },
  { name: "Mouse", stock: 0 },
];

const discontinued = products.filter((product) => product.stock < 0);

console.log(Array.isArray(discontinued));
console.log(discontinued.length);
true
0
🔑 REMEMBER —

A new result array does not mean new retained objects. filter() copies references into a new outer array.

Practical Filtering Patterns

A production predicate is easier to read when it states the business rule directly. This canonical example uses one product dataset for numeric thresholds, object properties, combined conditions, text search, nullish cleanup, and reusable predicates.

const products = [
  {
    id: "p1",
    name: "Mechanical Keyboard",
    category: "accessories",
    price: 120,
    stock: 3,
    active: true,
    tags: ["keyboard", "office"],
  },
  {
    id: "p2",
    name: "Wireless Mouse",
    category: "accessories",
    price: 45,
    stock: 0,
    active: true,
    tags: ["mouse"],
  },
  {
    id: "p3",
    name: "USB-C Hub",
    category: "accessories",
    price: 70,
    stock: 8,
    active: false,
  },
  {
    id: "p4",
    name: "Desk Lamp",
    category: "office",
    price: 55,
    stock: null,
    active: true,
    tags: ["lighting", "office"],
  },
  {
    id: "p5",
    name: "Monitor Stand",
    category: "office",
    price: 90,
    stock: 5,
    active: true,
    tags: ["monitor", "office"],
  },
];

const isActive = (product) => product.active === true;
const isInStock = (product) =>
  product.stock !== null &&
  product.stock !== undefined &&
  product.stock > 0;

const costsAtMost = (maximum) =>
  (product) => product.price <= maximum;

const hasSearchText = (query) => {
  const normalizedQuery = query.trim().toLowerCase();

  return (product) => {
    const searchableText = [
      product.name,
      product.category,
      ...(product.tags ?? []),
    ]
      .join(" ")
      .toLowerCase();

    return searchableText.includes(normalizedQuery);
  };
};

const affordable = products.filter(costsAtMost(60));
const sellable = products.filter(
  (product) => isActive(product) && isInStock(product)
);
const officeOrLowStock = products.filter(
  (product) => product.category === "office" || product.stock === 0
);
const matchingSearch = products.filter(hasSearchText("OFFICE"));

console.log(affordable.map((product) => product.id).join(","));
console.log(sellable.map((product) => product.id).join(","));
console.log(officeOrLowStock.map((product) => product.id).join(","));
console.log(matchingSearch.map((product) => product.id).join(","));
p2,p4
p1,p5
p2,p4,p5
p1,p4,p5

Each predicate handles one concern:

  • costsAtMost(60) creates a predicate with a numeric boundary.
  • isActive checks an object property explicitly.
  • isActive(product) && isInStock(product) requires both conditions.
  • The office or low-stock predicate accepts either condition.
  • hasSearchText() normalizes both the query and product text.
  • product.tags ?? [] handles the optional tags property without treating a missing property as an error.

Named predicates become valuable once a rule appears in more than one place. They provide one location for boundary decisions such as whether a maximum price is inclusive. They are also simple to test without running an entire search interface.

Removing only null and undefined requires an explicit nullish check:

const products = [
  { id: "p1", reorderLevel: 3 },
  { id: "p2", reorderLevel: 0 },
  { id: "p3", reorderLevel: null },
  { id: "p4" },
  { id: "p5", reorderLevel: 8 },
];

const productsWithReorderLevels = products.filter(
  (product) =>
    product.reorderLevel !== null && product.reorderLevel !== undefined
);

console.log(
  productsWithReorderLevels.map((product) => product.id).join(",")
);
p1,p2,p5

The product whose reorder level is zero remains because zero is legitimate data. A shorter equivalent is product.reorderLevel != null, because that loose comparison matches both null and undefined, but the two explicit checks are easier for a beginner to recognize.

Arrow functions make small predicates compact, but their return rules matter. The ES6 features guide explains how concise and block-bodied arrows differ.

Choose filter(), find(), some(), or every()

Choose the method from the result shape needed by the calling code.

NeedMethodResult
Every matching elementfilter()A new array, possibly empty
The first matching elementfind()One element or undefined
Whether any element passessome()true or false
Whether all elements passevery()true or false
Awaited tests or custom early controlfor...ofWhatever the loop builds or returns

Using the product model, these questions require different methods:

const products = [
  { id: "p1", active: true, stock: 3 },
  { id: "p2", active: true, stock: 0 },
  { id: "p3", active: false, stock: 8 },
];

const activeProducts = products.filter((product) => product.active);
const firstOutOfStock = products.find((product) => product.stock === 0);
const hasInactiveProduct = products.some((product) => !product.active);
const allHaveStock = products.every((product) => product.stock > 0);

console.log(activeProducts.map((product) => product.id).join(","));
console.log(firstOutOfStock?.id);
console.log(hasInactiveProduct);
console.log(allHaveStock);
p1,p2
p2
true
false

filter() collects all matches, so it processes the traversal range even after finding one. find() is appropriate when later matches do not matter. some() stops with true after its first passing element. every() stops with false after its first failing element.

Question: stock === 0?filterfindsomeeveryp1 ✕p2 ✓p3 ✕[ p2 ]p1 ✕p2 ✓STOPp2p1 ✕p2 ✓STOPtruep1 ✕STOP: onefailedfalsefilter must collect all matches; the others canknow sooner
The result shape determines how far each method must scan.

A deliberate loop is clearer when a test must be awaited or the code needs break, continue, an early return, or custom error handling.

const products = [
  { id: "p1", active: true, stock: 3 },
  { id: "p2", active: true, stock: 0 },
  { id: "p3", active: false, stock: 8 },
];

async function isAvailable(product) {
  return product.active && product.stock > 0;
}

async function collectAvailableProducts(items) {
  const result = [];

  for (const product of items) {
    if (await isAvailable(product)) {
      result.push(product);
    }
  }

  return result;
}

collectAvailableProducts(products).then((result) => {
  console.log(result.map((product) => product.id).join(","));
});
p1

A loop is not a fallback to be ashamed of. It exposes the sequence directly and supports control flow that array callbacks do not express well.

Five filter() Bugs That Look Correct

Missing a return from a block-bodied arrow

Braces create a function body. They do not return the condition automatically.

const prices = [20, 60, 40];

const broken = prices.filter((price) => {
  price <= 40;
});

const corrected = prices.filter((price) => {
  return price <= 40;
});

console.log(JSON.stringify(broken));
console.log(JSON.stringify(corrected));
[]
[20,40]

The broken callback returns undefined, which is falsy, for every element. Use an explicit return or remove the braces: prices.filter((price) => price <= 40).

Using filter(Boolean) when falsy values are valid

filter(Boolean) removes all falsy values. That can silently delete valid data such as a zero price, an empty label, or a stored false.

const products = [
  { id: "p1", storedValue: 0 },
  { id: "p2", storedValue: false },
  { id: "p3", storedValue: "" },
  { id: "p4", storedValue: null },
  { id: "p5" },
  { id: "p6", storedValue: 25 },
];

const values = products.map((product) => product.storedValue);
const truthyOnly = values.filter(Boolean);
const nullishRemoved = values.filter(
  (value) => value !== null && value !== undefined
);

console.log(JSON.stringify(truthyOnly));
console.log(JSON.stringify(nullishRemoved));
[25]
[0,false,"",25]

Use filter(Boolean) only when every falsy value is unwanted. Otherwise, name the exact values to remove.

Passing an async predicate to filter()

An async function always returns a Promise. filter() checks that Promise synchronously, and the Promise object is truthy. The eventual false value arrives too late.

The decision happens before resolutionBroken timingasynctest(p2)PromiseobjectKEEP p2filter sees a truthy object immediatelyresolvesfalse laterignoredCorrect timingrun testsawait allflagsfilter byflagsp1 true · p2 false· p3 false
An async predicate returns a truthy Promise now and its Boolean later.
const products = [
  { id: "p1", active: true, stock: 3 },
  { id: "p2", active: true, stock: 0 },
  { id: "p3", active: false, stock: 8 },
];

async function isAvailable(product) {
  return product.active && product.stock > 0;
}

async function demonstrateBug() {
  const broken = products.filter(async (product) => {
    return isAvailable(product);
  });

  console.log(broken.map((product) => product.id).join(","));
}

demonstrateBug();
p1,p2,p3

Resolve all tests first, then filter by their resolved flags:

const products = [
  { id: "p1", active: true, stock: 3 },
  { id: "p2", active: true, stock: 0 },
  { id: "p3", active: false, stock: 8 },
];

async function isAvailable(product) {
  return product.active && product.stock > 0;
}

async function filterAvailable(items) {
  const flags = await Promise.all(items.map(isAvailable));
  return items.filter((product, index) => flags[index]);
}

filterAvailable(products).then((result) => {
  console.log(result.map((product) => product.id).join(","));
});
p1

This keeps each resolved flag aligned with the product at the same index. Use a for...of loop instead when tests should run one at a time or when processing must stop early. The Promises and async/await guide covers the surrounding execution model.

Assuming filter() deeply copies objects

The outer array is new. Retained objects are shared references. Create new objects when later code must edit the filtered result without editing the source.

const products = [
  { id: "p1", price: 120 },
  { id: "p2", price: 40 },
];

const discounted = products
  .filter((product) => product.price >= 100)
  .map((product) => ({
    ...product,
    price: product.price * 0.9,
  }));

console.log(products[0].price);
console.log(discounted[0].price);
console.log(products[0] === discounted[0]);
120
108
false

The map() step creates a new object for each match. Nested objects inside those products would still be shared unless copied at the required level.

Mutating the source inside the predicate

filter() itself does not directly mutate its source, but the predicate can. This produces code whose later results depend on earlier side effects.

const products = [
  { id: "p1", price: 20 },
  { id: "p2", price: -40 },
  { id: "p3", price: 60 },
];

const broken = products.filter((product, index, source) => {
  if (source[index + 1]) {
    source[index + 1].price = Math.max(0, source[index + 1].price);
  }
  return product.price >= 20;
});

console.log(products.map((product) => product.price).join(","));
console.log(broken.map((product) => product.id).join(","));
20,0,60
p1,p3

The first callback changes the next product’s price before that product is visited.

Prepare changed values before filtering:

const products = [
  { id: "p1", price: 20 },
  { id: "p2", price: -40 },
  { id: "p3", price: 60 },
];

const normalizedProducts = products.map((product) => ({
  ...product,
  price: Math.max(0, product.price),
}));
const selectedProducts = normalizedProducts.filter(
  (product) => product.price >= 20
);

console.log(products.map((product) => product.price).join(","));
console.log(normalizedProducts.map((product) => product.price).join(","));
console.log(selectedProducts.map((product) => product.id).join(","));
20,-40,60
20,0,60
p1,p3

Keeping predicates free of source mutation makes their results easier to test. More cases like missing returns and accidental coercion appear in common JavaScript mistakes.

⚠️ GOTCHA —

filter() does not await callbacks, deep-copy objects, or prevent predicate side effects. Each assumption produces code that can look correct during a quick review.

Edge Cases and Production Judgment

The traversal range is fixed before the first predicate call. If the callback appends elements, those new indexes are not visited. If it changes an existing unvisited element, filter() reads the changed value when that index is reached. If it deletes an unvisited element, that index is skipped.

This is more precise than saying filter() is mutation-proof. It is not. The method leaves mutation to the callback, and mutation can affect the result.

For an array traversal of length n, filtering performs linear work across the fixed index range and builds storage for the retained results. A predicate that performs its own scan can make the total work larger. For example, calling includes() on another large array inside every predicate call adds repeated searching.

Production tests should cover the boundaries that define the predicate:

const assert = require("node:assert/strict");

const costsAtMost = (maximum) =>
  (product) => product.price <= maximum;

const products = [
  { id: "below", price: 49 },
  { id: "boundary", price: 50 },
  { id: "above", price: 51 },
  { id: "free", price: 0 },
  { id: "missing" },
];

assert.deepEqual([].filter(costsAtMost(50)), []);
assert.deepEqual(products.filter(costsAtMost(-1)), []);
assert.deepEqual(
  products.slice(0, 4).filter(costsAtMost(51)),
  products.slice(0, 4)
);

const selected = products.filter(costsAtMost(50));
assert.deepEqual(
  selected.map((product) => product.id),
  ["below", "boundary", "free"]
);
assert.equal(selected.some((product) => product.id === "above"), false);
assert.equal(selected.some((product) => product.id === "missing"), false);
assert.equal(selected.some((product) => product.price === 0), true);
assert.strictEqual(selected[0], products[0]);

Where the Books Fit

Once predicates, truthiness, and result shapes feel natural, the next step is combining array methods with application state, asynchronous work, and testable data transformations. JavaScript in Three Months develops those working patterns alongside the language concepts behind them, while the JavaScript learning roadmap helps place them in a practical study order. Keep the predicate specific, test its boundaries, and choose the method whose result matches the question being asked.

Frequently asked questions

What does filter() do in JavaScript?

JavaScript filter() returns a new array containing every existing element whose callback produces a truthy result. It preserves the relative order of retained elements and returns an empty array when nothing matches.

Does filter() change the original array?

The filter() method does not directly change the source array, but its callback can mutate that array or the objects inside it. The result is a new outer array whose retained objects still share references with the source.

Why does filter(Boolean) remove zero?

filter(Boolean) keeps only truthy values, and zero is falsy in JavaScript. It also removes false, empty strings, null, undefined, NaN, negative zero, and 0n, so use an explicit nullish check when those other values are valid.

Can filter() use an async callback?

An async callback always returns a Promise, and Promise objects are truthy. Array filter() checks that Promise immediately instead of awaiting it, so resolve the tests with Promise.all before filtering or use an awaited for...of loop.

When should I use find() instead of filter()?

Use find() when you need the first matching element or undefined. Use filter() when you need an array containing every match, including the possibility of an empty array.