~/blog

filter() with an async callback keeps every element

published

#javascript#async#typescript

TL;DR

users.filter(async (u) => await isActive(u)) returns every user. An async function always returns a Promise, filter() only checks whether the callback’s return value is truthy, and a Promise object is truthy even when it will resolve to false. find(), some() and every() fail the same way. TypeScript accepts all four under --strict; @typescript-eslint/no-misused-promises catches them. The fix is to run the async checks first with Promise.all, then filter on the resolved booleans.

The problem

You need the active users, and “active” lives in a database, so the check is async. The obvious code runs without an error:

const users = [
  { id: 1, name: 'ana', active: true },
  { id: 2, name: 'beto', active: false },
  { id: 3, name: 'carla', active: true },
];

// Stands in for a database or HTTP lookup.
async function isActive(user) {
  return user.active;
}

const active = users.filter(async (user) => await isActive(user));
console.log(active.map((u) => u.name));

Output on Node 26.2.0:

[ 'ana', 'beto', 'carla' ]

beto is inactive and is still in the result. Nothing threw, nothing warned, and the array has the right type. With a real lookup the symptom is “the filter does nothing”, which is easy to blame on the data instead of the code.

Why it happens

filter() calls the callback for each element and keeps the element when the return value is truthy. It does not await anything. MDN’s wording is exact: the callback “should return a truthy value to keep the element in the resulting array, and a falsy value otherwise.”

An async function returns a Promise every time, whatever its body returns:

const result = (async () => false)();
console.log(result);
console.log(Boolean(result));
Promise { false }
true

The falsy values in JavaScript are false, 0, -0, 0n, "", null, undefined, NaN and document.all. Every object is truthy, and a Promise is an object. So filter() sees a truthy value for every element, keeps them all, and moves on before any of the promises settle.

The other predicate methods have the same contract and the same failure:

console.log(users.find(async (u) => u.id === 99));
console.log(users.some(async () => false));
console.log(users.every(async () => false));
{ id: 1, name: 'ana', active: true }
true
true

find() returns the first element no matter what you search for, some() is true for any non-empty array, and every() is true even when every check says no.

A failing lookup crashes the process

If the async check rejects, filter() still keeps everything, and the rejection is now attached to a Promise nobody is holding. A try/catch around the call does not help, because the error happens after filter() has returned:

const users = [{ id: 1 }, { id: 2 }, { id: 3 }];

async function isActive(user) {
  throw new Error(`lookup failed for ${user.id}`);
}

try {
  const active = users.filter(async (user) => await isActive(user));
  console.log('kept', active.length);
} catch (err) {
  console.log('caught', err.message);
}
kept 3
...
Error: lookup failed for 1
    at isActive (file:///.../post-crash.mjs:4:9)
    at file:///.../post-crash.mjs:8:53
    at Array.filter (<anonymous>)
...
Node.js v26.2.0

The catch block never runs. The script prints kept 3, then Node exits with code 1, because its default --unhandled-rejections mode is throw: an unhandled rejection with no unhandledRejection listener is raised as an uncaught exception. In a server that means one failed lookup inside a filter takes the process down.

What to do

1. Resolve the checks, then filter on the results. Map the array to promises, await them together, and use the booleans by index:

const keep = await Promise.all(users.map(isActive));
const activeUsers = users.filter((_, i) => keep[i]);
console.log(activeUsers.map((u) => u.name));
[ 'ana', 'carla' ]

Promise.all rejects as soon as any check rejects, so a failed lookup now reaches your catch instead of crashing the process. If one bad element should not sink the whole call, use Promise.allSettled and decide per element what a rejected check means.

2. Check one at a time when the calls must not overlap. Promise.all starts every lookup at once. When order matters, or the backend cannot take the burst, a plain loop awaits each check before starting the next:

const activeSeq = [];
for (const user of users) {
  if (await isActive(user)) activeSeq.push(user);
}
console.log(activeSeq.map((u) => u.name));
[ 'ana', 'carla' ]

3. Batch large arrays. Ten thousand rows through Promise.all is ten thousand concurrent requests. A middle ground runs one batch at a time and keeps the parallelism inside each batch:

async function filterInBatches(items, predicate, size = 50) {
  const out = [];
  for (let i = 0; i < items.length; i += size) {
    const batch = items.slice(i, i + size);
    const keep = await Promise.all(batch.map(predicate));
    out.push(...batch.filter((_, j) => keep[j]));
  }
  return out;
}

console.log((await filterInBatches(users, isActive, 2)).map((u) => u.name));
[ 'ana', 'carla' ]

4. Write find as a loop so it can stop early. Mapping the whole array would run every check even after a match. A loop returns on the first hit:

async function findAsync(items, predicate) {
  for (const item of items) {
    if (await predicate(item)) return item;
  }
  return undefined;
}

console.log((await findAsync(users, async (u) => u.id === 3))?.name);
console.log(await findAsync(users, async (u) => u.id === 99));
carla
undefined

For some and every, resolve the flags and test those: const flags = await Promise.all(users.map(isActive)), then flags.some(Boolean) and flags.every(Boolean) give true and false for the sample data.

Catch it before it ships

TypeScript will not save you here. The predicate parameter of filter, some and every in lib.es5.d.ts is typed as (value: T, index: number, array: T[]) => unknown, and Array.prototype.find is typed the same way, so a function returning Promise<boolean> fits. This file compiles with tsc --strict (TypeScript 6.0.3) and no errors:

type User = { id: number; name: string };
declare const users: User[];
declare function isActive(u: User): Promise<boolean>;

export const active = users.filter(async (u) => await isActive(u));
export const first = users.find(async (u) => await isActive(u));
export const any = users.some(async (u) => await isActive(u));
export const all = users.every(async (u) => await isActive(u));

The typescript-eslint rule no-misused-promises does catch it. Its checksConditionals option is on by default, and its documented examples include a filter callback that returns a Promise. Run against the file above with typescript-eslint 8.65.0, it flags all four lines:

  5:36  error  Expected a non-Promise value to be returned  @typescript-eslint/no-misused-promises
  6:33  error  Expected a non-Promise value to be returned  @typescript-eslint/no-misused-promises
  7:31  error  Expected a non-Promise value to be returned  @typescript-eslint/no-misused-promises
  8:32  error  Expected a non-Promise value to be returned  @typescript-eslint/no-misused-promises

It is a type-aware rule, so it needs parserOptions.project or projectService in the ESLint config. Plain JavaScript projects without type information get no warning from it.

Caveats

References