Falsy値を比較せずにそのまま判定に使うことはやめよう

2022/03/23に公開約14,300字4件のコメント

…という話を現場でしました。

こんにちは、クレスウェア株式会社の奥野賢太郎 ( @okunokentaro ) です。TypeScript をお使いのみなさんは、Falsy 値(フォルシーち)というものをご存知ですか。TypeScript や JavaScript を長年使っている読者であれば「そんなもの常識だろう」となるかもしれませんが、TypeScript からの入門者だったり、他言語から TypeScript へ移ってきたような「JavaScript 未経験の TypeScript 経験者」が近年現れ始めており、筆者には案外これが常識とも言い切れなくなっているという感覚があります。

この Falsy 値、もちろん知っているに越したことはないかもしれませんが、TypeScript 全盛となったこの時代にただでさえ他に覚えるべきことが多いなか、果たしてこれはずっと常識のままなのでしょうか?

2022 年に改めて TypeScript における Falsy 値と向き合う、本稿はそういった内容となります。対象読者は Falsy 値についてふわっと理解しているが更に理解しておきたい方、TypeScript でより厳密で明示的な型付けを採用したい方などです。Falsy 値という言葉を初めて聞く初心者には、難しすぎるかもしれません。

Falsy 値とはなにか

Falsy 値とはなんであるか、こう問われると案外難しい問題です。多くの JavaScript 経験者ならこう答えるでしょう。「下記の 6 つの値はすべて false として扱われる」

  • false
  • 0
  • NaN
  • ""
  • null
  • undefined

「これらの値はすべて false として扱われる」という理解をしている方は多いと思います。しかし「falseでない値(上記0""など)を false として扱う」というのはどういうことなのでしょうか。なぜfalseではない値もfalseとして扱われるのでしょうか。その理解を、より厳密にしておきたいと思います。

Boolean コンテキストを理解する

Boolean コンテキストとはなにか、今回はいきなり結論を述べる前に順を追って JavaScript の条件分岐とはどういうことかを確認してみることにします。仕様を確認することで、それがどういうことか、そして Falsy 値について考慮しなければならない状況がどんなときなのかを、理解することができます。

プログラミングにおける条件分岐

プログラムは「条件分岐処理」の連続です。古典的なプログラミング学習においても、条件分岐処理の習得は必須です。多くのプログラミング言語では処理の条件分岐を実現するための構造「if 文」が採用されています。また、条件分岐処理と並んでプログラミング学習における必須項目として挙げられる「繰り返し処理」は、処理を条件に応じて繰り返すため条件分岐の応用ともいえます。この概念も多くのプログラミング言語では「for 文」として採用されています。

JavaScript では if 文、for 文がともに採用されています。TypeScript もまた、JavaScript のスーパーセット言語を標榜しているため、if 文、for 文が採用されています。

Falsy 値は実際どう評価されているか

では、Boolean コンテキストを理解するために「if 文」を取り上げます。JavaScript の if 文において Falsy 値がどのように評価されているか、この点を曖昧なままにせず言語仕様をもとに理解を深めましょう。JavaScript の標準仕様を定める ECMAScript の仕様を確認します。

if 文は、ECMAScript の仕様上ではThe if Statementとして定義されています。本稿の話題に直接絡まない構文部分の解読や解説は割愛しますが、if (この箇所) { ... } の「この箇所」に相当するものが何かわかればよさそうです。この箇所は構文仕様上ではExpressionとされています。このExpression定義上ではAssignmentExpressionという定義を含む再帰構造をとっており、あらゆるAssignmentExpressionを含められるとされています。MDN の if 文の記事にて、わざわざ if 文の中で変数xに変数yを代入している例が掲載されているのはx = yAssignmentExpressionであるためです。

if ((x = y)) {
  /* do something */
}

そして、if 文の実行時評価 (Runtime Semantics: Evaluation) 順を定義した仕様自体としては、こういったExpressionにどういう内容が含まれているかは関与せず、その評価はそれぞれのExpression側の仕様に移譲しています。(14.6.2.1)

問題は次です。Expressionの評価結果を受けて if 文は振る舞いを評価します。if 文の実行時評価の14.6.2.2では次のように記載されています。

Let exprValue be ToBoolean(? GetValue(exprRef)).

ここです。ToBoolean()という箇所で「その値が Falsy 値なのかどうか」という関心が作用します。(GetValue(exprRef)Expressionの評価結果から具体的な値を求める操作の部分ですが、この挙動の詳細は本稿では割愛します)

ToBoolean 抽象操作

Falsy 値を理解するために注目すべき仕様が「ToBoolean 抽象操作」です。抽象操作は訳語であり、原典では"Abstract Operations"と記載されているこの概念は、JavaScript の標準的な関数による様々な「型変換」について変換の仕様を定めています。

型変換は ECMAScript 仕様書の初版、 1997 年から存在する概念です。ToBooleanもその一つで、初版では以下の値がfalseに変換されるとあります。

  • false(正確には、変換されずそのまま)
  • 0, -0, NaN
  • ""
  • null
  • undefined

この内容からわかる通り、当時存在しなかったBigIntIsHTMLDDAに絡む評価を除いて、すべて当時と 2022 年で変わりありません。この歴史の長さが JavaScript 熟練者が「Falsy 値といえばこれ」とすぐさま挙げられる理由です。

1997 年当時の仕様書では、この節は"Abstract Operations"とはなっておらず、単に"Type Conversion"とされていました。初版には次のように記載されています。

The ECMAScript runtime system performs automatic type conversion as needed

最新の ECMAScript の仕様では次のように記載されています。

The ECMAScript language implicitly performs automatic type conversion as needed

最新版では"implicitly"(暗黙的)のニュアンスが追記されていることがわかります。そう、TypeScript による explicitly(明示的)な型付けが進んでいるこの時代において、TypeScript が JavaScript に変換されて動作する以上「TypeScript を利用する explicitly を心がける開発者」であっても、言語仕様として implicitly な型変換が行われる可能性を常に考慮せざるを得ないのです。せっかく TypeScript を利用しているにも関わらず、そんなことまで考慮しないといけないようでは、脳のリソースを無駄に使ってしまいますので止めたいところです。

Boolean コンテキストとはなんだったのか

if 文の仕様の話に戻ります。if 文のExpressionの実行時評価においてこの ToBoolean 抽象操作が行われ、ToBoolean の結果がtruefalseのどちらになるかによって、そこからどちらのブロックに進むか(仕様書上では first Statement を評価するか、second Statement を評価するか)が決定します。ここから「Expressionの実行時評価の結果が Falsy 値であるかどうかを気にする場面」こそが Boolean コンテキストと呼べうるのではないかと推察できます。

Boolean コンテキスト (Boolean contexts) を ECMAScript の仕様書で調べても、この 2 語が連なった語彙は登場しません。MDN や JavaScript に関する他の記事でこういった語彙を使っているものがしばしば見受けられますが、実は定義がないのです。つまり"contexts"は"Boolean"を解説するための地の文での単語として使われています。そこでこの Boolean コンテキストとはどういう状況で用いられているか仕様と記事を照らし合わせると、やはり「ToBoolean 抽象操作が行われる状況」を Boolean コンテキストと呼んでいるようなのです。

ということは「ECMAScript の仕様上、ToBoolean 抽象操作が行われると定義された箇所」で我々は Falsy 値の暗黙的な変換を考慮すればよさそうです。

Boolean コンテキストが現れる状況を理解する

前節によって、Boolean コンテキストを本稿においては「ToBoolean 抽象操作が行われる状況」と定義できそうです。では ToBoolean 抽象操作が if 文以外のどういうときに出てくるのか、最新の ECMAScript 仕様書から調べてみましょう。tc39.es のサイトでは、特定の仕様が他のどこから参照されているのか一覧できる機能があります。

while 文

while()に指定した式の評価結果がfalseになるまでブロック内を繰り返し実行します。このwhile()に指定する式に対して ToBoolean 抽象操作が行われ、すなわちここで Boolean コンテキストを成します。

let n = 0;

while (n < 3) {
  n++;
}

do-while 文

while 文と同様に、do-while 文でもwhile()に指定した式の評価結果がfalseになるまでdoブロック内を繰り返し実行します。このwhile()に指定する式は Boolean コンテキストを成します。

let result = "";
let i = 0;

do {
  i = i + 1;
  result = result + i;
} while (i < 5);

for 文

for 文は 3 つの式を取り、そのうち 2 つ目の式については Boolean コンテキストとなります。この式の評価結果がfalseになるまで、ブロック内の文が繰り返し処理されます。 (14.7.4.3.3.a.iii)

let str = "";

for (let i = 0; i < 9; i++) {
  str = str + i;
}

論理積演算子 &&

&&が繋がる式を評価し、最初にfalseとなった式の値を返し、全てがtrueだった場合に最後の式の値を返します。このときの評価について、論理積演算子が繋がるすべての式は Boolean コンテキストとなります。

論理和演算子 ||

||は論理積演算子と同様に評価しますが、最初にtrueとなった式の値を返し、全てfalseだった場合に最後の式の値を返します。このときも Boolean コンテキストを成します。このとき演算子の優先順位は&&が上となります。本稿では&&=, ||=については割愛します。

条件演算子 operand ? operand : operand

これは三項演算子と呼ばれることも多いです。最初の式(:の左)が Boolean コンテキストを成します。

if 文を使うか条件演算子を使うかで、しばしば界隈で議論になっている印象ですが、if 文でも条件演算子でも Boolean コンテキストを成しているという点においては同じであると言えます。ただし、ToBoolean 抽象操作の結果で分岐する部分が、 if 文では Statement(文)であることに対し、条件演算子では Expression(式)であることに違いがあります。

論理否定演算子 !

boolean値に対して、その真偽を反転させるのに使用する演算子です。例えば、アプリにおいて画面内の項目が選択されているときにtrueとなるboolean型の変数isSelectedがあったとして、こういう使い方をします。これはとてもよく見かけると思います。

if (!isSelected) {
  // 項目が選択されていないときはこの処理
  return;
}
// 項目の選択中はこの処理

論理否定演算子を伴うことができる式のことをUnaryExpressionとして定義されており、UnaryExpression の詳細をみていくとPrimaryExpressionが内包されていることから、雑な理解をするとすれば「だいたいいろんなものに!を付けてよい」となります。この「いろんなもの」にはあらゆる Falsy 値が該当するので、次のような書き方は構文上まったく問題ありません。

!"";
!0;
!NaN;
!false;
!undefined;
!null;

よって、こう書くこともなんら問題ありません。

!"".length;
!(1 - 1);

これは、これらが論理否定演算子のあとにくるべき Expression に該当しているからです。そして論理否定演算子によって、その Expression の評価結果は ToBoolean 抽象操作にかけられるため、Boolean コンテキストを成します。

二重否定

これは演算子ではありませんが!!のことです。二重否定はMDN にも取り上げられていますが、構文上は論理否定演算子で評価した結果を論理否定演算子で評価する形ですので、!!として 1 度の評価をするという定義はされていません(*1)。

つまり次のように評価されていると見なせます。

!!false; // !(!false) と解釈できる
!true; // (!false)の評価結果はtrueであるためこうなる
false; // !trueの評価結果はfalseである

論理否定演算子の評価結果は、必ず true または false となるため、二重否定は 1 つめの論理否定演算子の Boolean コンテキストを通じて「あらゆる値を boolean 値に変換するもの」として覚えている方も多いのではないかと思います。

Falsy 値を比較せずにそのまま判定に使うことはやめよう

表題のトピックに到達しました。本稿では、Boolean コンテキストとは何であるかという理解を共有した上で、Boolean コンテキスト上で Falsy 値をどう扱うのが好ましいかを提案し解釈してもらうことを目的としています。そのため Boolean コンテキストについての解説を長めにとりました。

JavaScript 時代の悪習

「Falsy 値を比較せずにそのまま判定に使うことはやめよう」という表題は、Boolean コンテキストについてなんら説明していない状態である程度状況を想像してもらうために付けたものです。これをここまで読み進めてきた理解で書き直すと「false以外の Falsy 値を Boolean コンテキストに渡すことをやめよう」と言い換えることができます。

どういうことか説明するために、引数に渡された名前に基づいて表示するラベルを返すgetLabel()関数を例に示します。

function getLabel(name: string): string {
  if (!name) {
    return "名無しさん";
  }
  return `${name}さん`;
}

ここで、名前がない場合は「名無しさん」という文字列を返却したいという要件がありました。そのため「名前がない場合」を表現するためにif (!name)としています。まあ、ごくありふれた関数です。こういった書き方をしている方はとても多くいますし、この書き方に違和感がない方がほとんどでしょう。

ところがこういう書き方もできます。

function getLabel(name: string): string {
  if (!name.length) {
    return "名無しさん";
  }
  return `${name}さん`;
}

.lengthを見て、わざわざそこに論理否定演算子!を付けています。熟練者なら「いやいやこんな書き方はあえてしないでしょ」となるかもしれませんが、筆者が実際に現場で遭遇したコードです。

前者!nameと後者!name.lengthは同じ結果となりますが、Boolean コンテキストの解釈が異なります。namestring型の引数であるため、 ToBoolean 抽象操作にてfalseとなりうるのは""(空文字列)のときだけです。

そして、name.lengthという式にした場合、lengthnumber型の値を返すため(根拠1, 2number 型の値が ToBoolean 抽象操作によってfalse となりうるのは0, -0, NaNのときであることから、!name.lengthについては"".lengthの値が0になるためにfalseとなります。

もし TypeScript の tsconfig strictオプションがfalseだった場合、もっと熾烈なことになります。

!name!"", !undefined, !nullのいずれかである可能性があるため、これらに該当したら if ブロック内に進みます。name変数に"", undefined, nullという 3 つの Falsy 値が含まれる可能性があるからです。

筆者がかつての現場で実際に見たコードとしては、このような状況下でさらに!name || name == nullみたいな書き方がされていました。意図的にはたぶん!nameで空文字列であるかを判定し、||で「あるいは」として、name == nullundefinednullの可能性をまとめて潰しているのだと思われます。== nullとする状況は「undefinednullの判定をまとめたい」といった idiom 的側面があります。

しかしよく考えてみると、!nameだけですでにtrueになるため論理和演算子||より右の式は評価すらされません。評価すらされないという特性を短絡評価といいます。よってname == nullという式は、挙動や要件を深く解釈していない別人によって、あまり考えられずに書き足されたものであろうといった推測が立てられます。

こういった、TypeScript 熟練者からみたら「冗長である」と感じるようなコードは、実際の現場に山ほど転がっています。これは開発参加者の Falsy 値への理解の不足、演算子や Boolean コンテキストの認識不足、TypeScript による明示的な型付けの意識程度の差異によるものです。

こういった問題を乗り越えるために、従来であれば「JavaScript の Falsy 値についてちゃんと覚えよう」であったり「JavaScript の=====の違いを覚えよう」という話の流れになっていたと思います。ですがもう 2022 年です。

JavaScript が 20 年以上引っ張る悪習は TypeScript で変えていこうじゃないですか。

今どう書くか

では、その代わりにどう書けばいいか。簡単です、ToBoolean 抽象操作にはtruefalseしか渡らないようにすればよいのです。こうすれば、ToBoolean 抽象操作はtrueのときにtruefalseのときにfalseを返します。何も複雑なことはありません。

よって、先の例を取り上げるとgetLabel()はこのように書きます。

function getLabel(name: string): string {
  if (name === "") {
    return "名無しさん";
  }
  return `${name}さん`;
}

たったこれだけです。ここで反論が聞こえます。

「いやいや、長年!nameで書いてたし、TypeScript としても別に構文誤りではないのだから、JavaScript の特性を理解していないのが問題であって!nameと書くことは昔も今も問題ないのではないか。=== ''のほうが、かえって文字数を稼いでしまい冗長になっているぞ!」

では聞きますが、parseInt()number型の値が渡っていたらどう感じますか?

ToString 抽象操作に対する TypeScript での扱い

2022 年初頭、parseInt()の引数に数値を渡し、それが一見すると不可解な挙動を取ることに対して JavaScript を揶揄する流れが一部の SNS 界隈で起こりました。

console.log(parseInt(0.00005)); // 0
console.log(parseInt(0.000005)); // 0
console.log(parseInt(0.0000005)); // 5

流れからしてparseInt(0.0000005)0になりそうなものが、なぜか5になってしまうという挙動です。

この挙動は直感的かどうかはさておき仕様通りです。この話題にも ToBoolean 抽象操作と似たようなものとして、ToString 抽象操作というものが含まれます。parseInt()の挙動についての詳解は本稿の目指すところではないので他者の記事に譲りますが、parseInt()の内部で評価される ToString 抽象操作によって0.0000005"5e-7"求められ、この"5e-7"の 1 文字目5parseInt()の戻り値として表出したというわけです。

Boolean コンテキストを成す ToBoolean 抽象操作は、あらゆる値をtruefalseに「暗黙的に」変換しています。そして同様に ToString 抽象操作も、あらゆる値を文字列に「暗黙的に」変換します。この「暗黙的」な変換は JavaScript の悪習に直結しており、明示的な型付けを目指す TypeScript では、それを極力防げるようにしています。すなわちparseInt()のシグネチャの型定義によって、暗黙的な ToString 抽象操作が起こってしまうことを避けるようになっています。その型定義を引用します。

declare function parseInt(string: string, radix?: number): number;

https://github.com/microsoft/TypeScript/blob/7f1ec30b8eea79e84aeaf3c7b231aa5489b7439f/lib/lib.es5.d.ts#L41

parseInt()関数は JavaScript 上では「ToString 抽象操作を介するため、あらゆる型の値を渡しても動作できる」のですが、TypeScript としては明示的に「string 型しか渡せない」と定義しています。JavaScript であらゆる型の値が渡るからといって、その事実をそのままparseInt(string: any)のように定義しなかったのです。

では ToBoolean 抽象操作についてはどうでしょうか?

string型のname引数を!nameと記述して ToBoolean 抽象操作による暗黙的な型変換を起こしてboolean値を得ようが、name === ''として厳密等価演算子===によって文字列同士を比較し、その結果 boolean 型の値が返ろうが、TypeScript はその差をコンパイルエラーとはしません。ToString 抽象操作は型定義で防いだのですが、ToBoolean 抽象操作は防げていないのです。

筆者の主張は「TypeScript は暗黙的な型変換を避けるようなデザインに寄っているのだから、ToBoolean 抽象操作についてエラーとならなかったとしても言語の設計意図を汲み、Boolean コンテキストにおいてはfalse以外の Falsy 値をそのまま渡すべきではない」です。TypeScript コンパイラーは ToBoolean 抽象操作での暗黙的変換を防げていない、この点がその主張の理由です。

解決法

解決法については、まず Falsy 値とはなにか、Boolean コンテキストとはなにかという JavaScript の悪習や経緯を把握し、TypeScript 記述時には気をつけるということが挙げられます。しかし、そんなことはいくら気をつけても誤ってしまうときは誤ってしまいます。そこで、そういった矯正は自動化してしまうのがよいです。

https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/strict-boolean-expressions.md#strict-boolean-expressions

幸いなことに typescript-eslint ルールがありますし、細かな調整もできますので、幅広く導入できると思います。さあ、この lint ルールを今すぐインストールしましょう。

後記

ここまで、2022 年において Falsy 値を再考するというトピックで説明してきましたが、これらは全て「筆者がstrict-boolean-expressionsを導入する妥当性を言語化するため」でした。

!nameといった書き方は実際非常によく見かけますし、筆者も最近まで躊躇なくやっていましたので、もちろん一概に悪とはいえません。ただ、歴史上「==より===を使え」とか「tsconfig stricttrue にしておけ」といった、その時代時代に応じた「より安全に業務を遂行するための作法」が生まれている以上、Boolean コンテキストについても時代の流れに応じてそういった考え方が生まれるのは自然ではないかなと筆者は考えています。

このルールに一度慣れれば TypeScript 歴、JavaScript 歴を問わずに誰でも均質なコードを書くことができます。スキルの差や経験量の差が大きい案件ほど、こういった点へのケアをすることはチーム運営において有益なのではないかと考えます。

本稿は以上です。よろしければ記事の Like や拡散、筆者のフォロー、サポートなどいただけましたら励みになります。最後までありがとうございました、それではまた。

Discussion

!nameと記述していたので、大変参考になりました。

2点質問です。※どちらも特定の状況ではどうか?に関連する質問です。

まず1点目は、Vanilla JSによるDOM操作です。
DOM操作においては以下がbestという理解ですが、こちら同じ考えでしょうか? Bestと考えた理由は、もしfooが存在した場合はHTMLElementを取得し、!fooだとBoolean コンテキストとしてtrue or false以外が存在するからです。ただfooにはHTMLElement or nullなので、記事で指摘するような「"", undefined, nullという 3 つの Falsy 値が含まれる可能性」は無いと考え、例示したGoodのほうが記述量の観点で優れているという考えもあります。

const foo = document.querySelector(".foo");

// Good
if (!foo)
// Best
if (foo !== null)

続いて2点目です。こちらはjQueryによるDOM操作です。
jQueryの場合は、取得後のjQuery collectionにあるlengthプロパティを用いて以下の様に判定することがBestと考えましたが、考え一致しておりますでしょうか。

const $foo = $(".foo");

// Good
if ($foo.length)
// Best
if ($foo.length === 0)

以下のFAQではGoodの例が回答されています。

https://learn.jquery.com/using-jquery-core/faq/how-do-i-test-whether-an-element-exists/

以上、2点ご回答いただけると幸いです。もしより良い方法があればご教授願います。

Vanilla JSによるDOM操作

const foo = document.querySelector(".foo"); // Element | null
if (foo === null) {
  //
}

変数fooElement | nullと推論されるため、foo === nullあるいはfoo !== nullと書きます。

jQueryによるDOM操作

jQueryについては使用したことがないため、使用したことがないライブラリのベストプラクティスについては検討していません。本稿はTypeScriptとしてどうあるとよいだろうかを論じているため、他の文献や言説と相反することはあるかもしれませんし、それについてどちらを優先すべきかは私が論じるべきことではありません。

ご回答ありがとうございます。
ではjQueryに関しては実際にTSを導入して検討します。ありがとうございました。

むむむ、正論だけど、実行するか悩ましい...
Falsey値を考慮する事によるコードのシンプルさは、メリットになり得ると思えちゃう

特にTypescriptだと
例えば型がstringなら、
false 0 undefined null がその時点で除外されるのだから、
"" の除外でしかなく
値のないって意味合いで !valueはしっくりくる。objectもそう。

逆にnumber は、 0って値があるから
個別にチェックしないとって気がする
eslintで型が numberの時だけ、!value は NGってlintして欲しいかも。
string objectでチェックは要らないかなぁ...

ログインするとコメントできます