JavaScript のネストした連想配列に安全にアクセスするヘルパー関数を考える
JavaScript の仕様上よくやりがちな、TypeError: Cannot read property 'HOGE' of undefined
エラーに関して。
目次
問題は何か
JSON オブジェクトや複雑なデータを表現した連想配列があるとする。
const data = {
items: [
{
name: 'Foo',
options: {
isAdult: true
}
},
{
name: 'Bar'
}
]
};
例えばこんな data
という連想配列があったとする。トップレベルプロパティの items
が配列で、0
番目の要素は name
と options
というプロパティを持つ。そして options
の連想配列の中には isAdult
というプロパティがある。
こんな時、isAdult
プロパティの値を取得するには
const isAdult = data.items[0].options.isAdult;
と、ピリオドやブラケットで繋いでアクセスするワケだが、name: Bar
のプロパティのように options
プロパティがないモノに対して同様にアクセスしようとすると、
// name: 'Bar' の要素を拾うため items[1] を指定するが…
const isAdult = data.items[1].options.isAdult;
TypeError: Cannot read property 'isAdult' of undefined
このように、options
がない = undefined
であるために、undefined.isAdult
とアクセスしたのと同様の状態になってしまい、エラーになってしまうのだ。Java でいう NullPointerException と並んで、JavaScript ではよくやってしまいがちなエラーだ。
ネストの深いプロパティにも安全にアクセスしたい
さて、このようなオブジェクトに対し、安全にアクセスしたいと思うのは自然な流れだろうが、どうしたらいいか。
すぐに思いつくのは、プロパティの存在を確認する if
文を書くことだ。
let isAdult;
if(data.items &&
data.items[0] &&
data.items[0].options) {
isAdult = data.items[0].options.isAdult;
}
このように1階層ずつ存在チェックをしていくワケだ。
この実装はもっと汎用化できるので、後ほど紹介する。
値が取れなかった時にどう動くべき?
ココで気にしないといけないのは、「対象のプロパティが存在しなかった場合にどうハンドリングすべきか」ということだ。
存在しないプロパティにアクセスしようとして TypeError
が発生するのはある意味妥当なことで、そのプロパティにアクセスして値を取得できなければ処理を続行できないこともあるワケだ。
一方で、API から拾ったデータなどで、そのプロパティが存在するとは限らない場合や、null
・undefined
ならそれでも構わない、という場合もあるだろう。どの階層のプロパティが存在するか・しないか、が気にならない場合もあるだろう。もしくは「結局連想配列を操作したいから、プロパティが存在しなければ空オブジェクト {}
が欲しい」という要件もあるかもしれない。
つまり、安全にアクセスすること自体は比較的容易に実装できるが、途中でプロパティが存在しなかった時に何を返すべきか、例外とすべきか、という点は、要件によるのだ。
対象のプロパティが存在しなければ undefined
とする
今回は、対象のプロパティやその親プロパティが存在しなければ、いずれも undefined
を返す実装で考えてみる。どの階層でプロパティが存在しなかろうと、目的のプロパティは存在しないのだから undefined
に変わりはない、という考え方だ。
先人の知恵を見てみる
…と、ココまで書いたので実装に移ろうか、と思ったのだが、ちょっと調べてみると既に同様のライブラリ、ユーティリティ関数は多く見られたので、それらのコードを見てみることにする。
Lodash#get()
一番有名なのは Lodash
というライブラリの _.get()
メソッドだろう。実際のコードは以下の2箇所 (v4.17.15 より)。
function get(object, path, defaultValue) {
var result = object == null ? undefined : baseGet(object, path);
return result === undefined ? defaultValue : result;
}
対象の object
が null
か undefined
の場合は undefined
に統一し、それ以外は baseGet()
関数でプロパティを見つけてくる。存在しない場合は undefined
となる。
その結果 (result
) が undefined
かどうかチェックし、undefined
の場合は defaultValue
を返す、値が存在すればその値を返す、という形。
function baseGet(object, path) {
path = castPath(path, object);
var index = 0,
length = path.length;
while (object != null && index < length) {
object = object[toKey(path[index++])];
}
return (index && index == length) ? object : undefined;
}
castPath()
や toKey()
など独自の関数もあるが、ココは型を揃えてるだけなので流す。
path
で受け取った 'data.items.0.options.isAdult'
といった文字列をピリオドで分割し配列化する。その配列を元に while
ループで1階層ずつ掘り下げていくだけ。対象の階層のプロパティが存在しない場合は object
が undefined
になるので while
ループを抜ける。対象の階層まで辿り着いていればそのプロパティの値を返し、そうでなければ undefined
を返す、という作りになっている。よくできている。
- 参考 : 【lodash】getでのnullの扱い | HAFILOG
- 参考 : 【JavaScript】ネストされたObjectのキーが存在するかチェックする | Black Everyday Company
phina.js
の $get()
次は、phina.js
というライブラリから、Object.prototype
を拡張して実装する $get()
という関数。
Object.defineProperty(Object.prototype, '$get', {
value: function(key) {
return key.split('.').reduce(function(t, v) {
return t && t[v];
}, this);
},
});
key
をピリオドで分割 (split()
) して配列にしたあと、Array.prototype.reduce()
を使ってチェックしている。
t && t[v]
で存在確認した上で1階層掘り下げたプロパティを返す作りになっている。ココで t
が Falsy だった場合は undefined
が返される。
Object
のプロトタイプとして実装しているので this
を使っているが、汎用的な関数にするとしたらこんな感じか。
function get(object, key) {
return key.split('.').reduce((t, v) => {
return t && t[v];
}, object);
}
このコードは簡潔ではあるが、先程の Lodash#get()
と比べて大きな問題がある。それは、プロパティが存在しない階層に到達した後も reduce()
によるループが続行してしまう点である。中でやってるのはプロパティの存在確認程度だが、key
で指定したピリオドの数だけ必ずループが走ることになるので、厳密には性能面でちょっと微妙かな、という感じ。
DownloadThisVideo
ツールの get()
DownloadThisVideo という Twitter Bot のコードから見つけた。
以下の util.js
にある get()
関数がイイカンジだった。
const get = (object, path) => {
let lookup = Object.assign({}, object);
let keys = path.split('.');
for (let key of keys) {
if (lookup[key]) {
lookup = lookup[key];
} else {
return null;
}
}
return lookup;
};
object
を Object.assign()
でコピーしてから処理を始めている。while
や reduce
ではなく for of
ループで処理している。
コチラは、対象のプロパティが見つからなくなった時点で return null
しているので、プロパティが存在しなかった場合は undefined
ではなく null
が返されることに注意。
その他…
その他、StackOverflow にいくつかの実装があったので、リンクだけ紹介しておく。
- javascript - What are the best ways to reference branches of a JSON tree structure? - Stack Overflow
new Function()
を使う方法
- Testing nested objects as undefined in Javascript - Stack Overflow
- プロパティ名を可変長引数で受け取っている
- Test for existence of nested JavaScript object key - Stack Overflow
- javascript - adding to json property that may or may not exist yet - Stack Overflow
自分はこう実装する
ということで、既に色々な実装を見てきて、汎用利用するなら Lodash が使いやすそうだが、この関数を単体で実装するなら、というのを自分なりに考えてみた。
/**
* 指定のオブジェクトから、指定のパスまで掘り下げて値を取得する
*
* @param {*} object オブジェクト
* @param {string} path パスの文字列。引数 object のトップレベルプロパティから
* ピリオド区切りで記す。配列の添字もピリオドで記す。
* ex. 'items.0.options.name'
* @return {*} 連想配列の値。取得できなかった場合は undefined が返される
*/
function get(object, path) {
let lookup = Object.assign({}, object);
const keys = `${path}`.split('.');
const length = keys.length;
for(let index = 0; index < length; index++) {
if(lookup[keys[index]] == null) {
return index === length - 1 ? lookup[keys[index]] : undefined;
}
lookup = lookup[keys[index]];
}
return lookup;
}
見た感じは DownloadThisVideo
ツールの get()
に近いだろうか。引数の object
に再代入するのは参照が変わっちゃいそうなので念のため Object.assign()
でコピーしといた。コレは引数 object
が Array であっても問題ない。
while
を使った Lodash#get()
は少々コードが読みづらかったので、平易な for
ループにした。
オブジェクト == null
での曖昧等価比較は、null
と undefined
にヒットする。こうなったらループを抜けて return
したいのだが、最終的なプロパティに null
が格納されていた場合は、undefined
ではなく null
を正確に返したいので、添字をチェックして生の値を返すか undefined
を返すことにした。
この関数の動きを試すため、以下のようなデータを用意する。
const data = {
object: {
zero: 0,
one: 1,
zeroStr: '0',
oneStr: '1',
str: 'hogefuga',
nullValue: null,
undefinedValue: undefined,
array: [
{ name: 'TEST 1' },
{ name: 'TEST 2' }
],
childObject: {
name: 'Child Object'
}
}
};
const array = [
{ name: 'Test' }
];
それぞれの結果は以下のとおり。
get(data, 'object');
get(data, 'object.zero');
get(data, 'object.one');
get(data, 'object.zeroStr');
get(data, 'object.oneStr')
get(data, 'object.zero');
get(data, 'object.str');
get(data, 'object.str.0'); // 0 = 1文字目だけ取れる
get(data, 'object.nullValue'); // null が返る
get(data, 'object.nullValue.DUMMY'); // undefined
get(data, 'object.undefinedValue'); // undefined
get(data, 'object.array');
get(data, 'object.array.0');
get(data, 'object.array.0.name');
get(data, 'object.array.1');
get(data, 'object.array.2'); // 3つ目の要素はないので undefined
get(data, 'object.childObject.name');
get(data, 'object.DUMMY.name'); // undefined
get(array, '0.name') // 'Test' が取れる
コレで思ったとおりのヘルパー関数ができた。