🐇

ミスを誘発する Optional Chaining, array?.every とその回避策について

2024/02/18に公開

Optional chaining は null や undefined な可能性があるオブジェクトのプロパティやメソッドに TypeError を起こさずにアクセスできる演算子です。この記事は JavaScript ですべての例を書いてありますが、いくつかの言語でも似たような演算子があります。

const a = null;
const b = "Hello";

console.log(a?.length); // => undefined
console.log(b?.length); // => 5;
console.log(a.length); // => Uncaught TypeError: Cannot read properties of null (reading 'length')

記述量を減らしたり、コードの見通しを良くすることができる便利な演算子ですが、組み合わせによっては意外な落とし穴があります。

ミスを誘発する array?.every

例えば次のような関数 isAllSeven を考えます。スロットゲームか何かだと思ってください。

/**
 * すべての要素が7であるかどうかを判定し、結果をコンソールに出力する
 * @param {number[]=} numbers
 */
function isAllSeven(numbers) {
  if (numbers?.every((number) => number === 7)){
    console.log('Yes, all are 7');
  } else {
    console.log('No, not all are 7');
  }
}

この関数 isAllSeven に何通りか入力を考えてみると、次のような結果になります。

isAllSeven([7, 7, 7]); // => Yes, all are 7
isAllSeven([1, 7, 7]); // => No, not all are 7
isAllSeven([]); // => Yes, all are 7

空配列 [] を渡したとき、true になる挙動はひょっとしたら直感に反するかもしれませんが、定義から考えると自然な挙動とされています(知らなかった人は覚えましょう)。スロットゲームとしては欠陥かもしれませんけどね。

さて、この関数 isAllSevennull を渡すとどうなるでしょうか?

isAllSeven(null); // => No, not all are 7

false(Falsy) になります。Optional Chaining で undefined が返されるので当たり前です。というわけで、この関数は空配列と null で挙動が異なります。

array?.some の場合

では、some の場合はどうでしょうか?次のような関数 isAnySeven を考えます。

/**
 * いずれかの要素が7であるかどうかを判定し、結果をコンソールに出力する
 * @param {number[]=} numbers
 */
function isAnySeven(numbers) {
  if (numbers?.some((number) => number === 7)){
    console.log('Yes, some are 7');
  } else {
    console.log('No, none are 7');
  }
}

some の場合は、空配列と null が同じ結果になりますね。

isAnySeven([7, 7, 7]); // => Yes, some are 7
isAnySeven([1, 7, 7]); // => Yes, some are 7
isAnySeven([]); // => No, none are 7
isAnySeven(null); // => No, none are 7

everysome で結果が異なるのは少し厄介です。例えば、最初の関数 isAllSeven の条件を some で書き直してしまうと(すべてが7であるということは、一つも7以外がないことと見做せる)、また別の動作になってしまいます。

/**
 * some ですべての要素が7であるかどうかを判定し、結果をコンソールに出力する
 * @param {number[]=} numbers
 */
function isAllSevenWithSome(numbers) {
  if (!numbers?.some((number) => number !== 7)){
    console.log('Yes, all are 7');
  } else {
    console.log('No, not all are 7');
  }
}

isAllSevenWithSome([7, 7, 7]); // => Yes, all are 7
isAllSevenWithSome([1, 7, 7]); // => No, not all are 7
isAllSevenWithSome([]); // => Yes, all are 7
isAllSevenWithSome(null); // => Yes, all are 7 (これが every バージョンと違う)

ド・モルガンが草葉の陰で泣いている。

回避策

Optional Chaining で渡したときの挙動それ自体は注意深くコードを読めば簡単に気づくことができます。しかし、問題なのはそれを他の開発者が読んだとき、果たしてそれが想定されている挙動なのか分からないことにあります(少なくとも僕はわかりません)。

// この if に null が渡されたときに false になるのは想定通り?それとも開発者の想定漏れ?
if (numbers?.every((number) => number === 7))

そもそもとして、null を渡したとき true であるべきか、false であるべきか(または、null を空配列と見做したいのか)というのは要件によりますよね。

なので、個人的には everysome を使うときには、Optional Chaining を避けるべきかなと思います。そもそも nullable にするなという話はさておき。

// この if は null が渡されたら true にしたいんやなあ
if (numbers == null ? true : numbers.every((number) => number === 7))

Discussion