JavaScript: better/cleaner functional way to find item from a nested array

Viewed 125

I am trying to write a find function to find items from matched items from a potentially nested array (without having to flat the array first) and I am trying to write in a FP way.

Here is my attempt:

const nestedArray = [
  [{ id: 1 }],
  [{ id: 2 }],
  [{ id: 3 }, [{ id: 4 }]],
  { id: 5 },
]

function findTarget(arr, predicate) {
  const helper = ([x, ...xs]) =>
    x === undefined
      ? null
      : predicate(x)
      ? x
      : Array.isArray(x)
      ? helper(x) ?? helper(xs)
      : helper(xs)

  return helper(arr)
}

findTarget(nestedArray, (item) => item.id === 5)

I think it works but it is not super readable and I am sure there are better ways to write such a function.

4 Answers

Here's how I would implement this using recursion:

function findTarget(value, predicate) {
  const isArray = Array.isArray(value);

  // Base case: if value is not array and predicate matches, we found a match
  if (!isArray) {
    if (predicate(value)) return value;
    return null;
  }

  // value must be an array, so run recursion and see if value exists
  for (const item of value) {
    const foundItem = findTarget(item, predicate);
    if (foundItem !== null) {
      return foundItem;
    }
  }

  // nothing found
  return null;
}

does the same thing that your code does and imo looks cleaner.

Since your example is calling predicate(x) in the first place, it will return a false positive when matching an array with an id: 5 property, so the Array.isArray(x) should go first to avoid this:

const nestedArray = [
  Object.assign([{ id: 1 }], { id: 5 }),
  [{ id: 2 }],
  [{ id: 3 }, [{ id: 4 }], null, [[{ id: 5 }]]],
  { id: 6 },
]

function findTargetLoop (arr, match) {
  if (!Array.isArray(arr))
    return arr && match(arr) ? arr : null;
  let item, i = 0;
  while (!(item = findTargetLoop(arr[i++], match)) && i < arr.length);
  return item ?? null;
}

const findTargetFunc = (arr, match, next) =>
  (next = ([item, ...rest]) =>
    Array.isArray(item) ? next(item) ?? next(rest)
    : item && match(item) ? item
    : rest.length ? next(rest) : null)(arr);


const match = item => item.id === 5;

console.log('with iterations', findTargetLoop(nestedArray, match));

console.log('pure functional', findTargetFunc(nestedArray, match));

Here's one approach I can think of. It uses the init function as a sentinel value to distinguish whether the element being searched for has already been found. Before returning, it invokes the accumulated value which is either () => undefined, or () => curr capturing the first element that matches the predicate.

const flatFind = (array, predicate) => {
  const init = () => undefined
  const reducer = (prev, curr) => (
    prev === init
    ? Array.isArray(curr)
      ? curr.reduce(reducer, init)
      : predicate(curr)
        ? () => curr
        : init
    : prev
  )
  return array.reduce(reducer, init)()
}

const nestedArray = [
  [{ id: 1 }],
  [{ id: 2 }],
  [{ id: 3 }, [{ id: 4 }]],
  { id: 5 },
]

console.log(flatFind(nestedArray, item => item.id === 5))

Your form is a good start but the helper is unnecessary and the order of conditions should be changed. Here we use inductive reasoning -

  1. If x is undefined, there is nothing left to match. Return no match.
  2. (inductive) x is defined. If x is an array, find within x or find within xs
  3. (inductive) x is defined and x is a non-array. If x matches predicate f, return the match
  4. (inductive) x is defined and x is a non-array that does not match the predicate f. Find within the sub-problem, xs.

const find = ([x, ...xs], f) =>
  x === undefined                 // 1
    ? null
: Array.isArray(x)                // 2
    ? find(x, f) ?? find(xs, f)
: f(x)                            // 3
    ? x
: find(xs, f)                     // 4

const t = [
  [{ id: 1 }],
  [{ id: 2 }],
  [{ id: 3 }, [{ id: 4 }]],
  { id: 5 },
]

console.log(find(t, (item) => item?.id === 5)) // { id: 5 }
console.log(find(t, (item) => item?.id === 9)) // null

Note, the behavior of your findTarget checks child arrays against the predicate and allows for return of arrays that match the predicate. This is inconsistent behaviour as it's not possible to match the outermost array and the predicate checks for .id properties on arrays. The find solution above avoids this by changing the order of conditions 2 and 3. If you want the ability to return array matches with your function, you can change the order to 1,3,2,4.

Related