Filtering In JavaScript: Beyond Array.filter()
filter() copies elements whose predicate returns truthy into a new array. Here's how it behaves with objects, Maps, async checks and TypeScript.
Array.prototype.filter copies every element whose callback returns a truthy value into a brand new array, and never touches the original. That is the whole method, and it is where every tutorial stops.
It is also where the real problems start. The array in front of you is probably an object, a Map, a NodeList, or an API response, the check you want to run is probably asynchronous, and the type you get back in TypeScript is probably not the one you wanted. This guide covers the behaviour that surprises people once they leave the array of numbers behind.
The short version:
filter()walks an array in order, copies the elements whose predicate returned truthy into a new array, and returns it. The copy is shallow, so surviving objects are shared references; the callback is synchronous, so anasyncpredicate keeps everything; and anything that is not an array needs a bridge such asObject.entries, spread, orArray.frombeforefilteris available at all.
What filter() Actually Does
A predicate is the callback you hand to filter(): the function whose return value decides whether an element is copied into the result. Truthy keeps, falsy skips. The signature is filter(callbackFn, thisArg), and the callback receives three arguments: the element, its index, and the array filter() was called upon. thisArg is a value to use as this while running the callback, which does nothing for arrow functions since they take this from their surroundings.
Two details matter more than they look:
- The return value is coerced to boolean, not compared to
true. Returning"yes",1, or an object all keep the element. Returning0,"",null,undefinedorNaNall skip it. - The result is a new array, always. Nothing matched? You get
[]back, nevernullorundefined, so.lengthand further chaining are safe.
console.log([0, 1, 2].filter((n) => n).join(","));
console.log([1, 2, 3].filter((n) => n > 99).length);
1,2
0
The mental model to hold for the rest of this article: filter copies the survivors, it does not remove the rejects. The source array ends the call byte for byte the way it started. Everything odd about the method follows from that one sentence.
This is old, settled machinery. filter was standardised in ECMA-262 5th edition, adopted in December 2009, and MDN lists it as Baseline widely available, having worked across browsers since July 2015. You never need a polyfill and you never need a library for it. If map and reduce are still fuzzy, the array methods guide covers the trio together.
Writing Predicates That Hold Up
Lead with the shape you actually want, then make the predicate say it out loud.
Multiple conditions go in one arrow function with && and ||, which is the single most common real use:
const products = [
{ name: "pen", price: 2, stock: 5 },
{ name: "desk", price: 200, stock: 1 },
{ name: "mug", price: 8, stock: 0 },
];
const cheapAndAvailable = products.filter((p) => p.price < 50 && p.stock > 0);
console.log(cheapAndAvailable.map((p) => p.name).join(","));
pen
Once a predicate grows past two clauses, pull it out and give it a name. Named predicates compose, and a chain of them reads as prose:
const inStock = (p) => p.stock > 0;
const cheaperThan = (max) => (p) => p.price < max;
const products = [
{ name: "pen", price: 2, stock: 5 },
{ name: "mug", price: 8, stock: 0 },
];
console.log(products.filter(inStock).filter(cheaperThan(50)).length);
1
Worth knowing what you just bought: two chained filters are two full passes over the data and two new arrays. For a hundred products that is free. For a hundred thousand inside a render loop, combine the predicates with && instead.
For nested properties, optional chaining keeps the predicate honest: orders.filter((o) => o.customer?.address?.city === "Lagos") skips rows with a missing customer instead of throwing.
filter(Boolean) is the famous one-liner for dropping falsy values, and also the famous foot-gun:
const values = [0, 1, "", "ok", null, NaN, 2];
console.log(values.filter(Boolean).join(","));
1,ok,2
The 0 and the "" are gone. If those were a valid quantity and a valid empty label, you just lost data. When you mean “no nulls”, write x != null, which catches null and undefined and nothing else.
Deduping leans on a Set. For primitives, spread the Set back out; for objects, keep a Set of keys you have already seen:
const rows = [{ id: 1 }, { id: 2 }, { id: 1 }];
const seen = new Set();
const unique = rows.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
});
console.log([...new Set(["js", "ts", "js"])].join(","));
console.log(unique.length);
js,ts
2
The search box deserves its own predicate. Users type jag and expect Jäger, so compare folded strings rather than raw ones. NFD normalisation splits ä into a plain a plus a combining accent mark, and \p{M} matches those marks, so the replace strips the accents and leaves the base letters behind:
const fold = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const names = ["Jäger", "Jones", "Jagermeister"];
console.log(names.filter((n) => fold(n).includes(fold("jag"))).join(","));
Jäger,Jagermeister
For whole-value matching, Intl collation says it properly. MDN describes usage: "search" as being for filtering a list of strings by testing each item for a full-string match against a key, where you only check whether compare() returns zero. With sensitivity: "base", only strings differing in base letters compare as unequal: a ≠ b, a = á, a = A. That equality is locale-dependent, which is the part people miss:
const de = "ä".localeCompare("a", "de", { sensitivity: "base" });
const sv = "ä".localeCompare("a", "sv", { sensitivity: "base" });
console.log(de === 0);
console.log(sv > 0);
true
true
In German, ä matches a. In Swedish it does not, because there ä is its own letter. Build one Intl.Collator outside the predicate and reuse it, rather than constructing a collator per element — which is exactly what localeCompare with an options object does on every call:
const collator = new Intl.Collator("de", { sensitivity: "base" });
const names = ["Jäger", "Jones", "Jagermeister"];
console.log(names.filter((n) => collator.compare(n, "jager") === 0).join(","));
Jäger
Four Traps In The Method Itself
A trap here means behaviour that is correct per the specification and still surprises you. These four account for most of the bug reports.
Trap 1: the copy is shallow. The new array is new. The objects inside it are the same objects.
const users = [{ name: "ada", active: true }, { name: "bob", active: false }];
const actives = users.filter((u) => u.active);
actives[0].name = "ADA";
console.log(users[0].name);
console.log(users.length);
ADA
2
Editing “the filtered list” edited the source. If the filtered rows are going into an editable form, clone them: users.filter(...).map((u) => ({ ...u })). This is one of the mistakes that bites everyone eventually.
Trap 2: sparse arrays. The callback runs only for indexes that have assigned values, so filter skips empty slots entirely. MDN’s own example, console.log([1, , undefined].filter((x) => x === undefined)), logs [undefined]: the hole was never visited, and the explicit undefined was.
const sparse = [1, , undefined];
console.log(sparse.length);
console.log(sparse.filter((x) => x === undefined).length);
3
1
Three slots go in, one match comes out, and the result is dense. Holes appear from new Array(5), from delete arr[2], and from setting an index past the end, so this shows up more often than it sounds.
Trap 3: mutating the array inside the predicate. The length is memorized before the loop starts, so elements appended during the walk are never visited, and elements you remove are not visited either.
const list = ["a", "b", "c"];
const kept = list.filter((x, i) => {
if (i === 0) list.push("d");
return true;
});
console.log(kept.join(","));
console.log(list.join(","));
a,b,c
a,b,c,d
"d" exists in the source and never reached the predicate. If a not-yet-visited element is changed by the callback, the value passed in is whatever it holds at the moment it gets visited.
Trap 4: there is no early exit. filter always walks the entire array, even when the first element already answered your question. When you want one item or a yes/no, say so:
const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
console.log(users.filter((u) => u.id === 2)[0].id);
console.log(users.find((u) => u.id === 2).id);
console.log(users.some((u) => u.id === 9));
2
2
false
find returns the first match (or undefined), findIndex its position, findLast the last one, and some a boolean. All four stop as soon as they can. filter(...)[0] also crashes on an empty result the moment you reach into it, which find does not.
Async Predicates: Why Your await Filter Keeps Everything
filter’s callback is synchronous, and it does not wait for anything. Hand it an async function and the callback returns a Promise, a Promise is an object, every object is truthy, so every element passes. The bug looks like the filter did nothing at all:
const items = [1, 2, 3, 4];
const isAllowed = async (n) => n % 2 === 0;
const wrong = items.filter(isAllowed);
console.log(wrong.length);
console.log(wrong.join(","));
4
1,2,3,4
Note what did not happen: no error, no warning, no rejected promise. Four elements in, four elements out.
The fix is to run the async work first and filter by the answers. Promise.all over map gives you an array of booleans in the same order as the input, which you then use as a mask:
const items = [1, 2, 3, 4];
const isAllowed = async (n) => n % 2 === 0;
async function keepAllowed(list) {
const mask = await Promise.all(list.map(isAllowed));
return list.filter((_, i) => mask[i]);
}
keepAllowed(items).then((r) => console.log(r.join(",")));
2,4
That fans out every check at once, which is what you want against a fast local check or a database you control. It also fails as a batch: Promise.all rejects on the first rejection, so one flaky check throws away every answer you already had — use Promise.allSettled and treat a rejected entry as a fail-closed false, or catch inside the mapped function. Against a rate-limited third-party API it is exactly what you do not want, and a serial for...of loop is the better shape:
const isAllowed = async (n) => n % 2 === 0;
async function keepSerial(list) {
const out = [];
for (const item of list) {
if (await isAllowed(item)) out.push(item);
}
return out;
}
keepSerial([1, 2, 3, 4]).then((r) => console.log(r.join(",")));
2,4
Same result, one request at a time. If the mechanics of await inside loops are still shaky, the promises and async/await guide works through the ordering rules.
Filtering Things That Are Not Arrays
The rule is simple: filter() lives on Array.prototype. Anything that is not an array needs a bridge first.
Plain objects go through Object.entries and back through Object.fromEntries. Each entry is a [key, value] array, so destructure it in the predicate:
const scores = { ada: 91, bob: 42, cy: 77 };
const passing = Object.fromEntries(
Object.entries(scores).filter(([, score]) => score >= 60)
);
console.log(JSON.stringify(passing));
{"ada":91,"cy":77}
Object.fromEntries() is the reverse of Object.entries() and has been Baseline widely available since January 2020.
Maps have get, set, has, keys, values, entries, forEach, delete and clear, and no filter. Spread the Map into an array of pairs, filter that, and rebuild:
const stock = new Map([["apple", 4], ["pear", 0], ["fig", 9]]);
const inStock = new Map([...stock].filter(([, n]) => n > 0));
console.log(inStock.size);
console.log([...inStock.keys()].join(","));
2
apple,fig
Sets are worth reaching for in the other direction, as the predicate itself. A Set gives you membership testing without scanning:
const allowed = new Set(["a", "b", "c"]);
console.log(["a", "z", "c"].filter((x) => allowed.has(x)).join(","));
a,c
When both sides are Sets, the set operations say it directly. Set.prototype.intersection() returns a new Set containing the elements in both, and has been Baseline newly available since June 2024:
const a = new Set([1, 3, 5, 7, 9]);
const b = new Set([1, 4, 9]);
const shared = a.intersection(b); // Set(2) { 1, 9 }
To filter a Set by an arbitrary predicate, spread it: new Set([...mySet].filter(fn)).
NodeLists are the classic DOM stumble. A NodeList’s instance methods are item(), entries(), forEach(), keys() and values(), with no filter() and no map(). MDN’s own advice is to convert it with Array.from(), so Array.from(document.querySelectorAll("input")).filter((el) => el.required) is the pattern to memorise. Spread works too: [...document.querySelectorAll("input")].
Array-likes such as arguments need no conversion at all, because filter is generic: it only expects the this value to have a length property and integer-keyed properties. MDN’s example, Array.prototype.filter.call({length: 3, 0: "a", 1: "b", 2: "c"}, (x) => x <= "b"), returns ["a", "b"] from a plain object.
function tally() {
return Array.prototype.filter.call(arguments, (x) => x > 2).length;
}
console.log(tally(1, 2, 3, 4));
2
Rest parameters (function tally(...nums)) give you a real array and are the better choice in new code, but .call is what rescues you inside old code you cannot change. Typed arrays are the one family that already has its own filter, and it hands back a new typed array rather than a plain one.
Filtering In TypeScript Without Losing The Type
The problem everyone used to hit: you filter out the undefineds and TypeScript still thinks they are there. That has not been true for two years — an explicit comparison narrows on its own now — so the live question is where the inference stops.
const birds: (string | undefined)[] = ["kea", undefined, "tui"];
// Before TypeScript 5.5 this was typed (string | undefined)[]; today it is string[].
const named = birds.filter((b) => b !== undefined);
TypeScript 5.5, released on 20 June 2024, fixed this with inferred type predicates. A callback that visibly narrows its parameter now infers x is T on its own, so countries.map(c => nationalBirds.get(c)).filter(bird => bird !== undefined) is typed Bird[] instead of (Bird | undefined)[].
The inference only happens when all four conditions hold:
- The function has no explicit return type and no type predicate annotation of its own.
- It has a single
returnstatement and no implicit returns. - It does not mutate its parameter.
- It returns a boolean expression tied to a refinement on the parameter.
Truthiness checks are the exception, and the scope of the exception is the part that catches people. The release notes are precise about it: a truthiness check will infer a type predicate for object types, where there is no ambiguity, so a (Bird | undefined)[] filtered with bird => !!bird still comes back as Bird[]. Where the inference stops is a union whose other member carries falsy values of its own — number (0), string (""), boolean (false):
const scores: (number | undefined)[] = [0, 91, undefined];
scores.filter((score) => !!score)
.map((score) => score.toFixed(1));
// ~~~~~ error
The survivors really are number; the trouble is the other branch. A type predicate is a two-way claim, so score is number would also tell TypeScript that everything filtered out is undefined — and 0 is a number that got filtered out too. The predicate would be a lie in that direction, and the zeros would vanish from whatever you compute next without anyone noticing. .filter(Boolean) fails earlier than that. Boolean is an ambient declaration whose call signature is written <T>(value?: T): boolean, so it falls at the first condition — an explicit return type — before ambiguity is ever considered. That is why filter(Boolean) narrows nothing, ever, not even the object-type case where x => !!x does.
Three fixes, best first:
- Compare explicitly.
.filter(score => score !== undefined)is the documented fix, and it is also the more correct predicate. - Write an explicit type guard when the check is genuinely complex:
function isDefined<T>(v: T | undefined): v is T { return v !== undefined; }. Know the cost: TypeScript does not check explicit type predicates against what it would have inferred, which makes anx is Tannotation about as safe as a type assertion. Get the logic wrong and the compiler believes you anyway. - Use
flatMapas an escape hatch.birds.flatMap((b) => (b === undefined ? [] : [b]))narrows through the return type without any predicate at all.
When filter() Is The Wrong Tool
Reach past filter when the question you are asking is not “which subset?”, or when the data is not a finite in-memory array. Three cases.
You are building buckets, not a subset. Filtering the same array three times with three predicates is three full passes to produce three arrays. Object.groupBy() does it in one, grouping an iterable by the string or symbol its callback returns and handing back a null-prototype object whose values are arrays of the original element references:
const tasks = [
{ title: "ship", status: "done" },
{ title: "write", status: "todo" },
{ title: "test", status: "done" },
];
const byStatus = Object.groupBy(tasks, (t) => t.status);
console.log(byStatus.done.length);
console.log(byStatus.todo[0].title);
2
write
Map.groupBy() is the sibling that returns a Map, so keys can be objects; you just have to read a group back with the same object reference you keyed it with. Both are Baseline newly available since March 2024, and both were originally proposed as array instance methods (Array.prototype.group and groupToMap) before moving to statics for web compatibility. Note the shared-reference rule from trap 1 applies here too: the grouped values are the original objects.
Your predicate is “is it in this other list?” A nested includes() inside a predicate scans the second list once per element of the first. Build a Set and test with has(), or use the set operations directly when both sides are Sets, as in the previous section.
The source is lazy or unbounded. Iterator.prototype.filter() returns an iterator helper that yields only the elements for which the callback returns true. Per MDN, the main advantage of iterator helpers over array methods is that they are lazy, producing the next value only when requested, which avoids unnecessary computation and lets them work with infinite iterators. The full set is eleven methods: drop, every, filter, find, flatMap, forEach, map, reduce, some, take and toArray. They shipped in V8 v12.2 (Chrome 122) and became Baseline newly available on 31 March 2025.
function* naturals() {
let n = 1;
while (true) yield n++;
}
const multiples = naturals().filter((n) => n % 7 === 0).take(3).toArray();
console.log(multiples.join(","));
7,14,21
Array.prototype.filter is not even an option here: the [...naturals()] you would need to get an array never returns. No intermediate arrays are allocated along the way, which also makes iterator helpers the better shape for a long chain over a large stream. Support is younger than the rest of this article, so check your runtime before shipping it.
The Decision Rule, In One Screen
Seven questions, in order.
| Ask | Reach for |
|---|---|
| Is it a plain array with a sync check? | filter |
| Is the predicate async? | Promise.all over map, then filter by the mask |
Is it an object, Map, NodeList or array-like? | Object.entries/fromEntries, spread into new Map, Array.from, or .call |
| Do I need one item or a yes/no? | find, findIndex, findLast, some |
| Do I need several buckets from one list? | Object.groupBy or Map.groupBy |
| Is the source infinite or streaming? | Iterator.prototype.filter |
| Am I in TypeScript? | Compare explicitly, never !!x |
The sentence worth memorising: filter copies survivors, shares their references, and always walks the whole list. Almost every filtering bug is one of those three clauses arriving as a surprise.
If you want this depth across the rest of the language, drawn out as notes rather than chapters, JavaScript in Three Months is the job-ready tier, and JavaScript for Staff Engineers carries the same treatment into iteration protocols, laziness and the allocation costs behind chained array methods. The JavaScript roadmap shows where the array methods sit relative to everything else you still need.
Frequently asked questions
Why does my async filter return everything?
Because filter()'s callback is synchronous. An async function always returns a Promise, a Promise is an object, and every object is truthy, so every element passes the test. Run the async checks first with Promise.all over map, then filter the original array using the resulting array of booleans as a mask.
How do I filter an object in JavaScript?
Plain objects have no filter() method, so convert and convert back: Object.entries(obj) gives you an array of [key, value] pairs, you filter that array, and Object.fromEntries() rebuilds an object from the pairs that survived. The same trick works for a Map if you spread it into an array first and pass the result to new Map().
Does filter() change the original array?
No. filter() creates a shallow copy containing the elements that passed the test, and the source array is left exactly as it was. Be careful with objects though: the survivors are the same object references, so mutating a property on a filtered item also changes it in the original array.
How do I filter without losing the type in TypeScript?
Use an explicit comparison such as .filter(x => x !== undefined) rather than a truthiness check. Since TypeScript 5.5, a predicate like that infers a type predicate and narrows (Bird | undefined)[] down to Bird[]. A truthiness check does not narrow when the union's other member has falsy values of its own: (number | undefined)[] stays as it is, because a false result could mean undefined or 0. For object types it does narrow, but the explicit comparison is still the safer habit.