Извлечение вложенных значений из объектов или опасности слепого приведения типов в 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'
В посте
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 табличке получаем такие результаты:
А на этом у меня всё, до новых встреч 👋