JavaScript のネストした連想配列に安全にアクセスするヘルパー関数を考える

JavaScript の仕様上よくやりがちな、TypeError: Cannot read property 'HOGE' of undefined エラーに関して。

目次

問題は何か

JSON オブジェクトや複雑なデータを表現した連想配列があるとする。

const data = {
  items: [
    {
      name: 'Foo',
      options: {
        isAdult: true
      }
    },
    {
      name: 'Bar'
    }
  ]
};

例えばこんな data という連想配列があったとする。トップレベルプロパティの items が配列で、0 番目の要素は nameoptions というプロパティを持つ。そして 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 から拾ったデータなどで、そのプロパティが存在するとは限らない場合や、nullundefined ならそれでも構わない、という場合もあるだろう。どの階層のプロパティが存在するか・しないか、が気にならない場合もあるだろう。もしくは「結局連想配列を操作したいから、プロパティが存在しなければ空オブジェクト {} が欲しい」という要件もあるかもしれない。

つまり、安全にアクセスすること自体は比較的容易に実装できるが、途中でプロパティが存在しなかった時に何を返すべきか、例外とすべきか、という点は、要件によるのだ。

対象のプロパティが存在しなければ 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;
}

対象の objectnullundefined の場合は 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階層ずつ掘り下げていくだけ。対象の階層のプロパティが存在しない場合は objectundefined になるので while ループを抜ける。対象の階層まで辿り着いていればそのプロパティの値を返し、そうでなければ undefined を返す、という作りになっている。よくできている。

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;
};

objectObject.assign() でコピーしてから処理を始めている。whilereduce ではなく for of ループで処理している。

コチラは、対象のプロパティが見つからなくなった時点で return null しているので、プロパティが存在しなかった場合は undefined ではなく null が返されることに注意。

その他…

その他、StackOverflow にいくつかの実装があったので、リンクだけ紹介しておく。

自分はこう実装する

ということで、既に色々な実装を見てきて、汎用利用するなら 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 での曖昧等価比較は、nullundefined にヒットする。こうなったらループを抜けて 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' が取れる

コレで思ったとおりのヘルパー関数ができた。