先日、社内の Slack に「フロントエンドの人はもちろん全問正解しないと…」という冗談 (?) と共に JavaScript クイズサイトのリンク https://jsisweird.com/ が流れてきたので挑戦したところ、散々な結果でした。筆者はフロントエンド専門ではありませんが、やはり、あの複雑な暗黙の型変換を理解せねば、と思い改めて調べてみました。
プリミティブ値とは
オブジェクトでない値はプリミティブ値 (primitive value) と呼ばれ、 数値、文字列値、真偽値、BigInt 値、Symbol 値、undefined 、null があります。
(undefined 、null は Undefined 型、Null 型の唯一の値として定義されています。)
以下の順に説明します。
- プリミティブ値から数値/文字列値への変換
- オブジェクト値から数値/文字列値への変換
- 真偽値への変換
プリミティブ値 → 数値
① null
, false
, ''
(空文字) は 0
に、true
は 1
になる
② 数値を表す文字列は、その数値になる
③ 上記以外は 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 値と数値は、混ぜて算術演算を行うと TypeError が投げられますが、これは、BigInt は小数を扱えないし、数値型は扱える値の範囲に制限があるので、どちらをどちらに変換しても精度が落ちる可能性があるため、そのように設計されています。補足: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 値をキーとしたメソッドです。
よって、 もし、toPrimitive を のように後から追加したら、既に 補足:Symbol 値をキーとしたメソッド
Symbol()
で作成した Symbol 値はユニーク値となることが保証されています。Symbol() === Symbol() //=> false
// 引数は toString の値に含まれるぐらいで特に意味はない
Symbol('foo') === Symbol('foo') //=> false
Symbol()
で作成した Symbol 値をキーにメソッドを追加すれば、既存のメソッドと重複する心配がありません。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 値が返ります。
プリミティブ値は、オブジェクトではないのにメソッド呼び出しやプロパティへのアクセスができるように見えます。 これは、一時的にラッパーオブジェクトが作成され、そのラッパーオブジェクトにアクセスしている (という風に動作するように仕様で決められている) ためです。 なお、数値、文字列値、真偽値のラッパーオブジェクトは new で明示的に作成できますが、BigInt 値、Symbol 値はできません。但し、補足:ラッパーオブジェクト
Symbol().toString() //=> 'Symbol()'
'foo'.length //=> 3
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]]
を持つオブジェクトが、このような動作をする」
と定義されました。DDA
は document 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
2 行目は特殊な変換を行っているように見えますが、そうではありません。補足:波括弧
{ }
に注意
'' + {a: 1} //=> '[object Object]'
{a: 1} + '' //=> 0
{ }
はブロック、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
等値演算子 ==
① 左右の型が同じなら ===
で比較する
② null
と undefined
は等しいとする
③ オブジェクト値とプリミティブ値 (null
と undefined
以外) の比較は、 オブジェクト値を hint = default
で変換して比較する
④ プリミティブ値 (null
と undefined
以外) は、数値 / 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 値の比較は、値が大きすぎると上手くいきません。 これは、JavaScript の数値が 倍精度浮動小数点数では、符号に 1 ビット、指数部に 11 ビット、仮数部に 52 ビットが割り当てられます。
この小数の部分が仮数部になりますが、補足:大きすぎる数値は比較できない?
4e+22 == 40000000000000000000000n //=> true
5e+22 == 50000000000000000000000n //=> false
IEEE 754-2019
という規格で定められた倍精度浮動小数点数で表現されるためです。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+22
は 49999999999999995805696
になってしまいます。5e+22 == 49999999999999995805696n //=> true
最後に
今度こそ完全に理解した! (ダニング=クルーガー効果)