ミスを誘発する Optional Chaining, array?.every とその回避策について
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 になる挙動はひょっとしたら直感に反するかもしれませんが、定義から考えると自然な挙動とされています(知らなかった人は覚えましょう)。スロットゲームとしては欠陥かもしれませんけどね。
さて、この関数 isAllSeven
に null
を渡すとどうなるでしょうか?
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
every
と some
で結果が異なるのは少し厄介です。例えば、最初の関数 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
を空配列と見做したいのか)というのは要件によりますよね。
なので、個人的には every
や some
を使うときには、Optional Chaining を避けるべきかなと思います。そもそも nullable にするなという話はさておき。
// この if は null が渡されたら true にしたいんやなあ
if (numbers == null ? true : numbers.every((number) => number === 7))
Discussion