S.Tominoff

Frontend / Backend developer

Извлечение вложенных значений из объектов или опасности слепого приведения типов в JavaScript

Недавно, на просторах dev.to, попался небольшой пост, где человек спрашивал варианты реализации функции для извлечения вложенных значений из объектов. В посте приводился вариант реализации подобной функции, однако он был совершенно некорректный. В этой статье разберём проблемы приведённого решения, а также напишем свою функцию с учётом этих проблем.

Задача

У нас имеется JavaScript объект произвольной формы и мы хотим безопасно извлечь данные из глубоко вложенных элементов. В случае, если элемент отсутствует, функция должна вернуть значение по умолчанию.

 

Пример работы искомой функции:

 

const obj = {
  x: {
    y: {
      z: 'the value'
    }
  }
}

getNestedProperty(obj, 'x.y.z') // -> 'the value'
getNestedProperty(obj, 'z.y.x', 'not found') // -> 'not found'

 

 

В посте на dev.to человек предлагает такое простое решение для этого:

 

function getNestedProperty(obj, path, defaultValue = '-') {
    const value = path.split('.').reduce((o, p) => o && o[p], obj);

    return value ? value : defaultValue;
}

 

И на первый взгляд всё вроде бы хорошо — код простой и понятный, занимает всего 2 строки. 👍

При этом, если применить эту функцию в примере выше, то мы даже получаем корректные результаты: 

 

Кажется, всё отлично? 🧐

 

Сложно поверить, но при том что функция вроде бы и работает, в этой реализации, все строки кода в теле функции содержат грубейшие ошибки 😱

Предтечей проблем этого кода является бездумное использование приведения типов и следующая за этим некорректная трактовка результатов и опять же использование приведения типов.

 

Конкретно в reduce цикле идёт наивная проверка — o && o[p], которая призвана проверять — существует ли свойство p в объекте o

 

Такой подход недопустим при работе с объектами! 🔥

 

После того, как мы получаем некорректный результат редукции, мы ещё и неправильно его трактуем, это приводит к избирательной работе функции, которое полностью зависит от приведения типов:

 

Значения есть, но функция их "не видит"! 😱

 

Проблема слепых проверок с приведением

 

Дело в том, что в языке JavaScript существует более 6 значений, приводя которые к логическому типу, мы всегда получаем false (это также называется falsy значениями).

Если ваш алгоритм должен корректно работать с falsy значениями, то вы непременно обязаны учитывать это и не использовать слепое приведение типов!

 

*   *   *

Список falsy значений небольшой и полностью приведён на mdn — https://developer.mozilla.org/en-US/docs/Glossary/Falsy, кроме того, рекомендую ознакомиться и с truthy значениями — https://developer.mozilla.org/en-US/docs/Glossary/Truthy

 

 

Давайте будем использовать объект, содержащий все основные falsy значения и посмотрим как функция будет с ним работать.

 

const obj = {
  a: { 
    b: { 
      c1: null,
      c2: false,
      c3: NaN,
      c4: '',
      c5: 0,
      c6: undefined,
      c7: `It's ok`
    }
  }
}

 

Убеждаемся что функция работает неправильно

 

Любопытный факт 🤓

В библиотеке Ramda есть функция pathOr, которая призвана для тех же целей, однако она тоже работает не совсем корректно, но об этом поговорим в конце статьи.

 

Как корректно работать с falsy значениями в объектах?

 

Во–первых, давайте выбросим наивную проверку вида o && o[p] и будем использовать метод hasOwnProperty:

const getNestedProperty = 
  (obj, path, defaultValue = '-') => {
    const value = path.split('.')
      .reduce(
        (o, p) => o.hasOwnProperty(p) && o[p],
        obj
      )

    return value ? value : defaultValue
}

 

Пока что этот код всё ещё работает некорректно, нам нужно исправить трактовку результата — если свойство не существует, результатом будет false, который как мы помним является валидным значением для нас.

 

Не-а, всё ещё не работает

 

В своём комментарии на dev.to, я предложил решение с использованием символов JavaScript. Идея элементарная — мы создаём собственный символ и используем его как маркер отсутствия данных:

 

const NOT_FOUND_SYMBOL = Symbol('function:getNestedProperty:notFound')

 

Теперь мы можем использовать этот символ в теле редукции:

 

.reduce((o, p) => o.hasOwnProperty(p) ? o[p] : NOT_FOUND_SYMBOL, obj)

 

В результате редукции мы будем получать либо произвольное значение, либо наш символ. Используя символ мы теперь легко можем определить в каком случае функция должна вернуть найденное значение, а в каком значение по умолчанию:

 

return value !== NOT_FOUND_SYMBOL ? value : defaultValue

 

Таким образом реализация функции становится примерно такой:

 

const NOT_FOUND_SYMBOL = Symbol('function:getNestedProperty:notFound')

function getNestedProperty (obj, path, defaultValue = '-') {
    const value = path.split('.').reduce((o, p) => o.hasOwnProperty(p) ? o[p] : NOT_FOUND_SYMBOL, obj)

    return value !== NOT_FOUND_SYMBOL ? value : defaultValue
}

 

Проверяем:

 

Еее, теперь всё работает как надо! 🚀

 

 

А есть решение без использования символов? И покороче бы... 🤓

 

Решение с использованием символов, на мой взгляд, наиболее наглядное, однако мы легко можем избавиться от них и написать гораздо более короткий вариант.

 

Чтобы этого добиться мы просто переносим значение по умолчанию в тело редуктора.

 

При этом исчезает необходимость проверять редуцированное значение, а значит для оформления функции понадобится сильно меньше кода:

 

const getNestedProperty = (obj, path, { defaultValue = '-', delimiter = '.' }) =>
  path.split(delimiter)
      .reduce(
         (o, p) => o.hasOwnProperty(p) ? o[p] : defaultValue,
         obj
      )

 

На заметку — представленное выше решение без символов далеко не идеально! 🔥

 

Представим, что у нас есть объект вида:

const obj = {
  a: {
    b: "it's b!"
  }
}

И мы вызываем функцию с таким значением по умолчанию:

getNestedProperty(obj, 'x.y', {
  defaultValue: {
    y: "WRONG!"
  }
})

 

В данном случае мы получим неправильный результат, поскольку после того как свойство x не будет обнаружено, reduce на следующем цикле получит значение по умолчанию, в котором есть свойство y.

Таким образом, вместо объекта { y: "WRONG!"} мы получаем просто строку "WRONG!"

 

Пограничный случай когда укороченная функция работает неправильно! 

 

Пара слов о Ramda и pathOr

 

В предыдущей статье я рассказывал о технике каррирования функций в JavaScript я упоминал великолепную библиотеку Ramda.

 

Так вот, в ней есть похожая по замыслу функция — pathOr, но результаты она выдаёт немного другие. А именно, pathOr не воспринимает null, NaN и undefined как валидные значения. Поэтому имейте это в виду когда используете эту функцию.

 

Применив её к нашей falsy табличке получаем такие результаты:

Небольшое сравнение

 

А на этом у меня всё, до новых встреч 👋