TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

JavaScript の複雑な型変換を今度こそ理解した

先日、社内の Slack に「フロントエンドの人はもちろん全問正解しないと…」という冗談 (?) と共に JavaScript クイズサイトのリンク https://jsisweird.com/ が流れてきたので挑戦したところ、散々な結果でした。筆者はフロントエンド専門ではありませんが、やはり、あの複雑な暗黙の型変換を理解せねば、と思い改めて調べてみました。

プリミティブ値とは

オブジェクトでない値はプリミティブ値 (primitive value) と呼ばれ、 数値、文字列値、真偽値、BigInt 値、Symbol 値、undefined 、null があります。
(undefined 、null は Undefined 型、Null 型の唯一の値として定義されています。)

以下の順に説明します。

  • プリミティブ値から数値/文字列値への変換
  • オブジェクト値から数値/文字列値への変換
  • 真偽値への変換

プリミティブ値 → 数値

null, false, ''(空文字) は 0 に、true1 になる
② 数値を表す文字列は、その数値になる
③ 上記以外は NaN になる
④ 文字列はトリムしてから変換される

算術演算において数値以外は数値に変換されるので、以下のように 0 を引けば他の値を数値に変換できます。

null - 0           //=> 0  
true - 0           //=> 1  
false - 0          //=> 0  
'' - 0             //=> 0  
'  ' - 0           //=> 0  
'3.14' - 0         //=> 3.14  
'  -1e-100  ' - 0  //=> -1e-100  
undefined - 0      //=> NaN  
'foo' - 0          //=> NaN  

① は C 言語の影響かな、と勝手に思っています。① だけ忘れないようにすれば、特に問題はないはず。

BigInt 、Symbol は勝手に変換されません。

1n - 0        // TypeError
Symbol() - 0  // TypeError

補足:BigInt 値と数値の演算


BigInt 値と数値は、混ぜて算術演算を行うと TypeError が投げられますが、これは、BigInt は小数を扱えないし、数値型は扱える値の範囲に制限があるので、どちらをどちらに変換しても精度が落ちる可能性があるため、そのように設計されています。


プリミティブ値 → 文字列値

だいたい、そのまま文字列になるので、特に問題はないはず。

`${null}`       //=> 'null'  
`${undefined}`  //=> 'undefined'  
`${NaN}`        //=> 'NaN'  
`${Infinity}`   //=> 'Infinity'  
`${true}`       //=> 'true'  
`${false}`      //=> 'false'  
`${0}`          //=> '0'  
`${-0}`         //=> '0'  
`${-1e-100}`    //=> '-1e-100'  
`${1n}`         //=> '1'  

BigInt で n がつかないこと、-0'-0' にならないことに注意。

補足:0-0


0-0 の違いが問題になることはないと思いますが、Object.is では違う値と判定されます。

0 === -0          //=> true  
Object.is(0, -0)  //=> false  

Symbol は勝手に変換されません。

`${Symbol()}`  // TypeError  

オブジェクト値 → プリミティブ値

オブジェクト値がプリミティブ値に変換されるときに呼ばれるメソッドは、toString, valueOf, toPrimitive の 3 つがあります。各メソッドについて説明した後、どのようなルールで呼ばれるのかを説明します。

toString

({a: 1}).toString()                    //=> '[object Object]'  
/^[a-z]+$/.toString()                  //=> '/^[a-z]+$/'  
new Date(2024, 0, 1).toString()        //=> 'Mon Jan 01 2024 00:00:00 GMT+0900 (日本標準時)'  
(function() { return 1; }).toString()  //=> 'function() { return 1; }'  
[1, [2, [3]]].toString()               //=> '1,2,3'  
[].toString()                          //=> ''  
[[[1]]].toString()                     //=> '1'  

デフォルトの toString は '[object Object]' というあまり意味のない文字列を返すので、意味のある値を返すように上書きされていることが多いです。

Array の toString が、join した文字列を返すことに注意が必要です。JavaScript の join は、flat してカンマで連結した文字列を返すので、上記のような結果になります。最後の 2 例は、数値を必要とする箇所では更に 0, 1 に変換されることになるので、まるで Array が数値として扱えるかのように見え、混乱の原因となります。

なお、toString という名前なのだから文字列を返すようにすべきですが、数値やオブジェクト値を返す toString を実装しても、JavaScript としては何も問題ありません。

valueOf

({a: 1}).valueOf()              //=> {a: 1}  
new Date(2024, 0, 1).valueOf()  //=> 1704034800000  
new Number(1).valueOf()         //=> 1  

デフォルトの valueOf は、そのオブジェクト自体を返す意味のないメソッドです。
Date の valueOf は、1970-01-01 00:00:00 (UTC) からのミリ秒を返します。
ラッパーオブジェクトの valueOf は、ラップしているプリミティブ値を返します。

toPrimitive

toString と valueOf と違い、toPrimitive は Symbol.toPrimitive という Symbol 値をキーとしたメソッドです。

補足:Symbol 値をキーとしたメソッド


Symbol() で作成した Symbol 値はユニーク値となることが保証されています。

Symbol() === Symbol()  //=> false

// 引数は toString の値に含まれるぐらいで特に意味はない
Symbol('foo') === Symbol('foo')  //=> false

よって、Symbol() で作成した Symbol 値をキーにメソッドを追加すれば、既存のメソッドと重複する心配がありません。

もし、toPrimitive を

Object.prototype.toPrimitive = function() { ...... }

のように後から追加したら、既に Object.prototype.toPrimitive を実装しているプログラムは正しく動作しなくなってしまいます。そこで、Symbol 値を新規に作成し、この値 (Symbol.toPrimitive に格納) をキーにすることで、既存のプログラムに影響がでないようにしたというわけです。

Object.prototype[Symbol.toPrimitive] = function() { ...... }

toPrimitive の挙動は、実際に toPrimitive を持つオブジェクトを作成してみると分かり易いです。

const foo = {
  [Symbol.toPrimitive](hint) {
    console.log(`hint = ${hint} で呼ばれました`);
    switch (hint) {
      case 'string':
        return 'FOO';
      case 'number':
        return 999;
      case 'default':
        return 'XXX';
      default:
        throw 'あり得ない!!!';
    }
  }
}

`${foo}`  //=> 'FOO' (「hint = string で呼ばれました」と表示)
foo - 0   //=> 999   (「hint = number で呼ばれました」と表示)
foo + ''  //=> 'XXX' (「hint = default で呼ばれました」と表示)

オブジェクト値からプリミティブ値への変更が必要な個所では、hint と呼ばれる値 (string, number, default のどれか) が裏側で設定され、toPrimitive が定義されていれば toPrimitive の引数に渡されます。
どの値が設定されるかは仕様書で細かく定義されていますが、以下を覚えておけば良いでしょう。

  • default : 二項演算子の + 、等値演算子 ==
  • string : テンプレートリテラルの補間等、文字列が必要な個所
  • number : 算術演算、比較演算等、数値が必要な個所

二項演算子の + は数値の加算にも文字列の連結にも使われるために default という特別の値になるのに対し、 同じく数値にも文字列にも使える比較演算子 (>, <= 等) は number なので注意してください。また、単項演算子の + も算術演算扱いで number です。

組み込みのオブジェクトで toPrimitive が定義されているのは、Date と Symbol のラッパーオブジェクトのみです。
Date の toPrimitive は、string のとき toString 、number 又は default のときは valueOf と同じ値が返ります。
Symbol のラッパーオブジェクトの toPrimitive は、hint の値に関わらずラップしている Symbol 値が返ります。

補足:ラッパーオブジェクト


プリミティブ値は、オブジェクトではないのにメソッド呼び出しやプロパティへのアクセスができるように見えます。

Symbol().toString()  //=> 'Symbol()'  
'foo'.length         //=> 3  

これは、一時的にラッパーオブジェクトが作成され、そのラッパーオブジェクトにアクセスしている (という風に動作するように仕様で決められている) ためです。

なお、数値、文字列値、真偽値のラッパーオブジェクトは new で明示的に作成できますが、BigInt 値、Symbol 値はできません。但し、Object() を使えば作成できます。

new String('foo')  // OK  
new Symbol()       // TypeError  

Object('foo')      // OK  
Object(Symbol())   // OK  

toString, valueOf, toPrimitive が呼ばれるルール

以下の通りです。

IF (toPrimitive が定義されている) {
  IF (toPrimitive がプリミティブ値を返す) toPrimitive の返り値 ELSE TypeError
} ELSE {
  IF (hint が string) {
    IF (toString が定義されておりプリミティブ値を返す) {
      toString の返り値
    } ELSE {
      IF (valueOf が定義されておりプリミティブ値を返す) valueOf の返り値 ELSE TypeError
    }
  } ELSE IF (hint が number 又は default) {
    IF (valueOf が定義されておりプリミティブ値を返す) {
      valueOf の返り値
    } ELSE {
      IF (toString が定義されておりプリミティブ値を返す) toString の返り値 ELSE TypeError
    }
  }
}

ルールは複雑ですが、上述の通り、valueOf はあってないようなものだし、toString が文字列値を返さないということも普通はないので「Date は toPrimitive 、その他は toString」で問題ないことが多いと思います。

補足:toString, valueOf が定義されていない場合


toString, valueOf が定義されていない場合というのは、以下が考えられます。

// prototype が null のオブジェクト  
const obj = Object.create(null)  
obj.toString  //=> undefined  
obj.valueOf   //=> undefined  

// delete 演算子で消す  
delete Object.prototype.toString  
delete Object.prototype.valueOf  
({a: 1}).toString  //=> undefined  
({a: 1}).valueOf   //=> undefined  

真偽値以外の値 → 真偽値

真偽値への変換については、false になる値を覚えれば終わりです。

  • false, null, undefined, 0, -0, 0n, NaN, ''(空文字) は false
  • 上記以外は true
!!null                //=> false  
!!undefined           //=> false  
!!0                   //=> false  
!!-0                  //=> false  
!!0n                  //=> false  
!!NaN                 //=> false  
!!''                  //=> false  
// 上記以外は true  
!!' '                 //=> true  
!!'0'                 //=> true  
!![]                  //=> true  
!!{}                  //=> true  
!!new Boolean(false)  //=> true  
!!Symbol()            //=> true  

補足:document.all


document.all というオブジェクトは上記のルールに従わず false になります。document.all は、どのブラウザで見ても存在していると思いますが、歴史的な経緯により、undefined であるかのように振る舞います。

// document.all は存在するが、  
document.all.toString()    //=> '[object HTMLAllCollection]'  

// 以下の場合 undefined であるかのように振る舞う  
typeof document.all        //=> 'undefined'  
document.all == undefined  //=> true  
document.all == null       //=> true  
!!document.all             //=> false  

この挙動をどう扱うかについて色々あったようですが、結局、
「内部スロット [[IsHTMLDDA]] を持つオブジェクトが、このような動作をする」
と定義されました。DDAdocument dot all の略で、[[IsHTMLDDA]] を持つオブジェクトは、勿論 document.all だけです。何とも無理矢理な定義ですが、意地でも定義に落とし込んでやるという心意気が、個人的には好きです。


以上で、一通り、型の変換について説明しましたが、数値にも文字列値にも使える二項演算子の + と比較演算子について補足します。最後に、もはや非推奨扱いの == についても説明します。

二項演算子の +

① Object があれば、まず Object を hint = default で変換する
② どちらかが文字列値なら、文字列値でない値を文字列値に変換して文字列結合する
③ どちらも文字列値でないなら、数値でない値を数値に変換して加算する

// Date が文字列値になるので文字列結合  
999 + new Date(2024, 0, 1)  //=> '999Mon Jan 01 2024 00:00:00 GMT+0900 (日本標準時)'  

// Array が文字列値になるので文字列結合  
[1] + undefined  //=> '1undefined'  

// 文字列値が無いので数値として加算  
true + null  //=> 1  

補足:波括弧 { } に注意


'' + {a: 1}  //=> '[object Object]'  
{a: 1} + ''  //=> 0  

2 行目は特殊な変換を行っているように見えますが、そうではありません。{ } はブロック、a: はラベル (普通はループから脱出するときに使う) と見做され、無意味にラベルの付いた 1 が評価されてブロックが終了した後、単項演算子の + があるので '' が数値に変換されて 0 になっているだけです。

// 丸括弧で囲めば問題ない  
({a: 1}) + ''  //=> '[object Object]'  

比較演算子 > >= < <=

① オブジェクト値は hint = number で変換して比較する
② 文字列値と文字列値は、文字列値として辞書的に比較する
③ 文字列値と文字列値の比較以外は、数値 / BigInt 値として比較する

  • 数値と BigInt 値は、そのまま比較できる
  • BigInt 値以外は、数値に変換して比較する。但し、BigInt 値と文字列の比較では、文字列値を BigInt 値に変換して比較する (変換できない場合は必ず false になる)
// Date は数値に変換される  
new Date(2000, 0, 1) >= 946652400000  //=> true  
new Date(2000, 0, 1) < 946652400000   //=> false  

// 文字列として比較 (([2] → '2', [1, 2] → '1,2')  
[2] > [1, 2]   //=> true  
[2] <= [1, 2]  //=> false  

// 数値として比較 ([2] → '2' → 2)  
[2] > 1   //=> true  
[2] <= 1  //=> false   

// 数値として比較 ([1, 2] → '1,2' → NaN)  
[1, 2] > 1   //=> false  
[1, 2] <= 1  //=> false   

// BigInt 値として比較 (文字列 → BigInt 値の場合もトリムされる)  
' 10000000000000000 ' > 9999999999999999n  //=> true  

// 数値 と BigInt 値の比較 (エラーにならない)  
2 > 1n  //=> true  

等値演算子 ==

① 左右の型が同じなら === で比較する
nullundefined は等しいとする
③ オブジェクト値とプリミティブ値 (nullundefined 以外) の比較は、 オブジェクト値を hint = default で変換して比較する
④ プリミティブ値 (nullundefined 以外) は、数値 / BigInt 値として比較する

  • 数値と BigInt 値は、同じ値を表しているなら等しいとする
  • BigInt 値以外は、数値に変換して比較する。但し、BigInt 値と文字列の比較では、文字列値を BigInt 値に変換して比較する (変換できない場合は等しくないとする)
 // Date は文字列に変換される  
new Date(2000, 0, 1) == 'Sat Jan 01 2000 00:00:00 GMT+0900 (日本標準時)'  //=> true  

// 型が同じなので === で比較  
[[1]] == [1]  //=> false  

// 数値として比較 ([[1]] → '1' → 1, true → 1)  
[[1]] == true  //=> true  

// 数値として比較
' 1e+2 ' == 100  //=> true

// BigInt 値として比較 ('1e+2' は BigInt の形式でないので変換できない)
' 1e+2 ' == 100n  //=> false

// 数値 と BigInt 値の比較 (エラーにならない)  
1 == 1n  //=> true  

補足:大きすぎる数値は比較できない?


数値と BigInt 値の比較は、値が大きすぎると上手くいきません。

4e+22 == 40000000000000000000000n  //=> true
5e+22 == 50000000000000000000000n  //=> false

これは、JavaScript の数値が IEEE 754-2019 という規格で定められた倍精度浮動小数点数で表現されるためです。

倍精度浮動小数点数では、符号に 1 ビット、指数部に 11 ビット、仮数部に 52 ビットが割り当てられます。 4e+22, 5e+22 は 2 進数を使って以下のように表現でき、

4e+22 = 1.000011110000110011110000011001001101110101011001001 × (2 の 75 乗)    // 仮数部は 51 桁
5e+22 = 1.01010010110100000010110001111110000101001010111101101 × (2 の 75 乗)  // 仮数部は 53 桁

この小数の部分が仮数部になりますが、5e+22 は 53 桁で、52 ビットに割り当てられず、最後の 1 ビットが落ちてしまい、結果、5e+2249999999999999995805696 になってしまいます。

5e+22 == 49999999999999995805696n  //=> true

最後に

今度こそ完全に理解した! (ダニング=クルーガー効果)

田中 彁(タナカ ???)
「彁」は 幽霊文字 で読み方は分かりません。


シナジーマーケティング株式会社では一緒に働く仲間を募集しています。