JS基礎いろいろー型強制(type coercion)
経緯
今回の話にした理由というのは、先日社内に起こった会話でした。とある社員のキーボードのsキーが故障気味になっていて、それで「sキーをおさずにsを出力する選手権」が開催。とある回答は以下となります。
(typeof({}+''))[+[]] // s
これを見た瞬間、「この発想に脱帽」と思いました。それで、せっかくなので自分の復習を兼ねて、JS基礎シリーズにも書いてみようと、今回の記事に至りました。
何が起こっているのか
Kyle Simpson氏が自分のシリーズ作、You Don't Know JS Yetで主張している、JSと言う言語を理解するために必要な三本柱の一つとなっているのが、Types and Coercionです(ちなみに後二つはprototypes, scope & closureです)。
この強制変換の仕組みが非常に煩わしく、初心者がよくハマってしまうところでもあります。言語設計上のトレイドオフもありながら、この仕様は「安全性」を引き換えに、「利便性」と「危険性」を同時にもたらしています。「JSは使ってから学ぶ」という主張をしたりする人もいて、この仕様は非常に軽視されやすい部分です。ただ言語仕様への理解をテストするために、この辺りの問題は面接にかなり向いていますので、そういった意味でも学んで損はないと思います。
JSは動的言語で、タイプに関連するチェックはほぼなく感じますが、値にタイプがないわけではありません。単純に、計算・処理が行われるようになるまで、値に対して「暗黙的な」強制タイプ変換が起こっているからです。この特性によって、想定外の「サプライズ」になることがあります。例えば、次の出力がわかるのでしょうか。
console.log(!!'false')
console.log('3' - 1)
console.log(1 + '')
console.log( + {} )
console.log( + [] )
console.log([[]] + 1)
console.log([{}] + 1)
console.log([] + {})
console.log({} + {})
console.log({} - {})
強制変換は他のJava、C、Rustなど静的型付け言語における、いわゆるタイプキャスト(type cast)とはまた性質が違います。と言うのは、もちろんコンパイル時のタイプチェックがないし、type coercionは実行時(runtime)にjsエンジンが勝手に行っている行為です。また、この「暗黙的」(implicit)なタイプ変換は、Number('123')
、Boolean('a')
、true.toString()
とかと言う顕在的な(explicit)タイプ変換と言うのも、ぱっと見で変換が行われているのがわかるかどうかと言う意味で若干違います(パッと見でわかると言うのは経験に左右されやすいが、一旦この表現にさせてください)。
つまり、この仕様は他の言語にはあまり見られない、JSの特徴的なものとして考えられます。「ts使えば良いじゃん」と思うのは最もだと思いながら、tsはあくまでもjsをベースにしているから、「なぜここのタイプが合わないのか」を理解するためにも、この仕様への理解が不可欠になっていると思います。
強制変換の仕組み
ここでは、ECMAスペックを軸に、中に定められている基本タイプ(primitive types)への変換の定義を少し説明します(BigInt
に関しては今回割愛)。
ToString
まず文字列への変換を見てみましょう。オリジナルのテーブルはこちらとなります。
変換元のタイプ | 変換結果 |
---|---|
Undefined |
"undefined" |
Null |
"null" |
Boolean |
"true" または"false"
|
Number |
Number::toString(arg) のリターン値 |
String |
インプット値のまま |
Symbol |
TypeError exception をスロー |
Object |
ToPrimitive のリターン値 -> ToString のリターン値 |
Undefined
やNull
はそのまま””をつけて文字列になっています。APIにリクエストを投げるときに、query stringがもしundefined
となっていたら、query stringは文字列に変換されるので、バックエンドで文字列の"undefined"
が届くのがこのためです。
ブールタイプも、値によって、"true"
か"false"
の文字列になります。これはtrue.toString()
と同じ結果となります。The answer is ${true}
を書くときに、true
は暗黙的に文字列に変換されています。
数字も然り、基本的に””で囲めばよいでしょう。NaN
も例外なく、"NaN"
になるだけです。
シンボルは暗黙的な変換ができません。これはシンボルの「唯一無二」の特性と関わるもので、暗黙的に変換することが許されていません。シンボルを文字列に変換するには、顕在的にtoString()
を呼び出すか、symbol.description
で記述を文字列として取り出すことが必要です。
let symbol = Symbol('special')
console.log(symbol) // type error
console.log(symbol.toString()) // 'Symbol(special)'
console.log(symbol.description) // 'special'
最後のObject
に関しては一旦伏せます。後で説明しますが、ToPrimitive
を理解したら自然に分かるようになります。
ToNumber
次はToNumber
の方です。
変換元のタイプ | 変換結果 |
---|---|
Undefined |
NaN |
Null |
+0 |
Boolean |
1 または+0
|
Number |
インプット値のまま |
String |
数字かNaN
|
Symbol |
TypeError exception をスロー |
Object |
ToPrimitive のリターン値 -> ToNumber のリターン値 |
+0
について今回のスコープ外になりますが、興味のある方はsigned zero
とかで検索してみても良いでしょう(こちらにも参考に)。注意したいのは、Undefined
とNull
が多くのところで結構似ている挙動になるが(undefined == null
)、数字に変換されるときは違います。
文字列から数字への変換の中身は結構複雑です。StringToNumber
の変換には、4つのステップがあります。
ステップ1では、インプットのテキスト(文字列というデータタイプのニュアンスではなく、プログラムが処理するインプットのテキスト)に対して、UTF-16エンコードされたUnicodeのコードポイントのリストに変換します。コードポイントというのは、U+1234
といったユニコード文字を表しているテキストのことです。
ステップ2では、1で得られたユニコードのリストを取り入れて、リテラルへ解析を試す。ステップ3では、ステップ2の解析のプロセスにもしエラーが発生したら、NaN
をリターンする。もし問題が発生しなければ、最後のステップ4に移し、いくつかのルールを元に数字へと変換する。例えば、ホワイトスペースのみの場合は、どれだけスペースあっても0
と変換したり、-/+
といった記号が入る場合は対応するポジティブ・ネガティブな数字に変換したりするとか。
正直ECMAを読み通しても、言語解析などの専門知識がないとかなり理解しにくい内容でした。StringToNumber
の変換をシンプルに言えば、文字列で記載されている「正しい数字」への変換を試みるが、失敗した場合は全てNaN
になるよ、とのことです。
よくハマりやすい例を見てみると、
Number("Infinity") // Infinity
Number("infinity") // NaN
Number("1") // 1
Number("-1") // -1
Number("-1+1") // NaN
Number(" ") // 0
最後のObject
のパターンはまたToPrimitive
の節で説明します。
ToBoolean
ToBoolean
も見てみましょう。
変換元のタイプ | 変換結果 |
---|---|
Undefined |
false |
Null |
false |
Boolean |
インプット値のまま |
Number |
+0 ,-0 , NaN の場合はfalse 、以外はtrue
|
String |
str.lengthが0ならfalse 、以外はtrue
|
Symbol |
true |
Object |
true |
ブールタイプの変換は一見あまり変哲もないテーブルですが、多少罠もあります。例えば、!![]
はfalse
と間違いやすいが、JSの配列はObject
なので、true
になります(これは筆者が過去のプロジェクトで見たことのあるミスです)。数字の場合もよく漏れやすいのは、文字列を数字に変換するのが失敗し、NaN
のままでブール変換されてfalse
となるケースです。
ブールタイプの変換には割とシンプルな側面があります。というのは、フォルシー(falsy)な値を覚えておけば、他は全部trueになることです。上記のテーブルから抜粋すると、以下となります。
undefined
null
false
0
NaN
""
ブールタイプへの変換方法は、Boolean(x)
だけではありません。他にもよく出会う強制変換が行われている箇所は以下となります。
-
!
と!!
、!a
はa
をブールタイプへ変換してから反転する - if文の条件、
if (...)
カッコ内の値はブールタイプへ変換する - for i ループの2個目のループ条件、上記と同じ
- whileループ(
do while
も含む)の条件、上記と同じ -
&&
演算、a && b
では、a
をブールタイプへ変換する -
||
演算、上記と同じく -
a ? b : c
三項条件演算、a
をブールタイプへ変換する
ToPrimitive
さて、JSのタイプを大きく分けると、primitiveとobjectの2種類に分けられます。つまり、オブジェクト({}
、[]
、Date
などを含む)以外は全部primitiveタイプとなります。それで、ToPrimitiveの操作は、オブジェクトタイプの値を、基本タイプへの変換を指しています。
ただ、基本タイプには複数存在するし、変換先のタイプはどうやって決まるの、と疑問になるでしょう。ここも若干複雑なステップがありますが、整理してみると:
-
@@toPrimitive
シンボルメソッドを実行、実行時に望ましい変更先のヒントはstring
かnumber
かを確認 - もし望ましい変更先が
string
の場合、変換の優先順位をToString -> ToNumber
とし、逆の場合はToNumber -> ToString
とする -
string
へ変換する場合はtoString()
メソッドを呼び出し、number
へ変換する場合はvalueOf
を呼び出す -
toString()
とvalueOf()
のリターン値が基本タイプとなれば、その時点で変換終了- 優先メソッドが失敗した場合は、候補メソッドの方を実行
- どれもダメだった場合(基本タイプの値に変換できず)は
TypeError
をスロー
それで、この望ましい変更先はどうやって決めるかがわかりづらいかもしれません。基本的にヒントなしの場合はデフォルトとしてnumber
と変換します。
だったら、ヒントありの場合というのはどんな場合だろうか。例えばテンプレート文字列に入れるとか、数字と数学演算(+/-/*など)するとか、が目印になります。ここで少し例で考えましょう。
const myObj = {
toString() {
console.log('toString invoked')
return 'my obj'
},
valueOf() {
console.log('valueOf invoked')
return -1
}
}
String(myObj) // my obj
myObj + 1 // 0
myObj + '' // '-1' => 意外かも?
-myObj // 1
myObj*100 // -100
三つ目の計算結果は文字列の'-1'
となっている。+
があるので数学演算ということで、myObj
を強制変換するときに優先的にvalueOf
が呼び出される、と勘違いされやすいが、本当は+''
はヒントなしのケースとなっています(この理由について+ xxx
は文字列連結や数字の掛け算という2つの解釈が可能のためヒントにはならないと個人的な推測)。ヒントなしの場合はデフォルトのnumber
が変換先なので、結果的にvalueOf
が呼び出されているだけです。ただ、+
演算子の二つの被演算子(operand)のいずれが文字列の場合は、文字列連結操作となる(こちら)ので、結果的にnumberがstringへ変換されます。
また、先ほどデフォルトのヒントがnumber
と述べましたが、例外としてDate
の@@toPrimitive
のデフォルトヒントはstring
となっています。
let d = new Date()
d + '' // '2023-01-28T09:05:02.163Z'
String(d) // '2023-01-28T09:05:02.163Z'
+d // 1674896702163
謎を解いていく
ヒントの正体をばらす
このヒントは一体なんなのかをさらに深掘りしていきます。実は、ToPrimitive
という操作が、シンボルとして定義されています。
const myObj = {
[Symbol.toPrimitive](hint) {
console.log(`toPrimitive invoked with hint: ${hint}`);
return 100
},
toString() {
console.log('toString invoked')
return 'my obj'
},
valueOf() {
console.log('valueOf invoked')
return -1
}
}
String(myObj) // toPrimitive invoked with hint: string
`${myObj} in a template string` // toPrimitive invoked with hint: string
+myObj // toPrimitive invoked with hint: number
myObj*3 // toPrimitive invoked with hint: number
myObj + '' // toPrimitive invoked with hint: default
ToString
とToNumber
のテーブルの最後の行を思い出してください。この変換には2つのステップがありますが、最初にToPrimitive
、つまりここで我々が定義したSymbol.toPrimitive
メソッドが実行されます。この段階で得られた基本タイプの値が、ヒントによってtoString
に渡すか、valueOf
に渡すかが決められます。この場合、我々が定義したtoString
またはvalueOf
が実行されません。もしtoPrimitive
の段階で基本タイプの値をリターンしなかったら、タイプ変換できないエラーがスローされます。
+[]
と+{}
の違いは?
今までのヒントで考えると、[]
にしても、{}
にしても、いずれもnumber
へ変換されるはずです。ただ結果は違います。
console.log(+{}) // NaN
console.log(+[]) // 0
+
を前につけることで、ヒントはnumber
だとわかって、それでvalueOf
が実行されますが、こちらの説明のように、valueOf
メソッドで返した値はオブジェクトそのままになります。
{}.valueOf() // {}
[].valueOf() // []
これでは基本タイプの値に変換できませんので、結局toString
がもう一度呼び出されます。薄々原因を察しているかもしれませんが、この違いを生じたのは、toString
実装の有無にあります。このtoString
はビルドインのObject
型({}
)には実装がないが、配列のtoString
メソッドはあります。
let l = [1,2,3]
l.toString() // '1,2,3'
[].toString() // ''
String([ null, undefined ]) // ','
配列のtoString
は、要素にコンマで連結しています。つまりarr.join()
のことです。ただ、join
メソッドはすべての配列要素を文字列へ変換するため、null
とundefined
は'null'
と'undefined'
になるはず、と思ったりするかもしれませんが、これはただの例外になります。
+[]
と+{}
の問題に戻ると、[]
は''
となり、これをnumber
に変換すれば、0
となるのです。一方で、toString
のおかげで、{}
が'[object Object]'
(次で説明)になり、数字にしたらNaN
になってしまいます。そのため、同じく数字への強制変換ですが、結果は違いました。
[object Object]
とは何?
[object Object]
という文字列はJS開発者にとって、誰しも見覚えのある「訳のわからない文字列」かと思いますが、これは、toString()
メソッドが実装されていないオブジェクトに対して、内部属性の[[Class]]
の値をリターンしている。もちろん、先ほどのmyObj
の例のように、toString()
を実装した場合は、実装されたメソッドが実行されます。
toString
メソッド以外にも、toPrimitive
の検証と同じく、Symbol.toStringTag
とのシンボルが存在します。
const myObj = {
get [Symbol.toStringTag]():{
return `myObj value: ${this.val}`
},
val: 100
}
myObj.toString() // '[object myObj value: 100]'
ここでの優先順位をはっきりとしましょう。もしとあるオブジェクトのtoString
メソッドが実装されている場合、このtoString
が最優先で実行されます。toString
は実装されていない場合、プロトタイプチェインを遡ることとなるため、結果的にObject.prototype.toString()
が実行されます。Symbol.toStringTag
が存在する場合、[object ${value of toStringTag}]
の形で出力します。もしSymbol.toStringTag
が存在しない場合、[object Object]
というデフォルト形式で出力するので、謎のダブルオブジェクトになるのです。
このtoStringTag
の値について、JS内蔵のオブジェクトタイプはそれぞれのタイプ名となっています。つまり、
元タイプ | 出力内容 |
---|---|
Null | [object Null] |
Undefined | [object Undefined] |
Array | [object Array] |
Function | [object Function] |
Date | [object Date] |
RegExp | [object RegExp] |
Boolean | [object Boolean] |
Number | [object Number] |
String | [object String] |
注意したいのは、ここのBoolean
, Number
, String
は基本タイプではなく、オブジェクトラッパー(new String('123')
とか)となっています。これらはタイプ的にオブジェクトになります。
23/04/23追記
このtoStringTag
の値に実はタイプチェックでかなり有用、typeof
、instanceof
よりも、勝手にこの値自体を上記のように編集しない限り、primitive/objectのタイプを全部カバーすることが可能。こちらの記事にも参考。
function getType(value) {
return Object.prototype.toString.call(value)
.replace(/^\[object (\S+)\]$/, '$1')
.toLowerCase();
}
==
と===
の違いは?
タイプの強制変換が最もよく見られるところとも言えよう。「とにかく==
はやめろ」と言われたことはありませんか。
その違いは一言言えば、強制変換の有無だけです。==
はIsLooselyEqual
の操作を意味しており、===
の方はIsStrictlyEqual
となります。
大原則として、
- 比較対象のタイプが一致すれば、
IsLooselyEqual
はIsStrictlyEqual
と同じ挙動となる - 比較対象のタイプが一致しない場合、
number
を優先ヒントとして、タイプが一致するまで強制変換し続ける
スペックで示されているLoosely
の場合の内容を抜粋すると、比較する二つの値にとって、
- タイプ一致の場合は、
IsStrictlyEqual
を実行 -
null
とundefined
の場合はtrue
-
number
とstring
を比較する場合、string
をToNumber
で変換してから比較 -
boolean
が入っている場合、boolean
をToNumber
で変換してから比較(つまり1
か0
となる) - 一つの値がオブジェクトタイプ、もう一つが基本タイプの場合、オブジェクトタイプを
ToPrimitive
で基本タイプに変換してから比較
例えば:
100 == '100' // true rule no.3
null == undefined // true rule no.2
'yes' == true // rule no.3 & no.4 => thus NaN == 1 is false
[] == false // rule no.4 & no.5 => valueOf returns [], calling toString, thus [] coerced to '', then to 0, 0 == 0 is true
[] == '0' // rule no.5 => when [] coerced to '', '' == '0' is false (same type already)
NaN == {} // rule no.5 => valueOf returns {}, thus calling toString, thus NaN == '[object Object]' is false
[] == [] // false emm..??
[] == ![] // true WTF?????
最後の二つは確かにおかしすぎる。
[] == []
これに関しては、この文脈ではかなり紛らわしいのですが、すでに同じタイプなので、タイプの強制変換は行われません。単純にオブジェクトタイプのデータはレファレンスなので、それぞれの[]
はメモリーないの違うところに保存されているからです。IsStrictlyEqual
の挙動と同じく、===
の結果と一致します。
それで最後のものを見てみよう。!
と!!
はブールタイプ変換する演算なので、これは比較より優先順位が高いので、先に![]
見る必要があります。ToBoolean
テーブルを参照すると、[]
が配列=Objectタイプなので、true
となります。!
は否定となるので、右側の![]
はfalse
となります。左側の[]
はすでに説明した通り、''
→0
のルートで数字の0
となります。すると、右側のfalse
が0
と変換され、このタイミングでタイプ一致して結果がtrue
と判断。
== true
は絶対避けたい
これまで見たら、すでにわかると思いますが、やはり見過ごしやすいので強調したいところです。
コードではあまりこの書き方をする人がいないかもしれません:
if (res == true) {
//...
}
それで、仮にres
がなぜか文字列になったら:
'true' == true // false 'true' coerced to number, thus NaN == 1 is false
もちろん、先ほどの[]
もありうる:
const res = []
if (res == false) { // [] coerced to '', then 0, thus 0 == 0 is true
//...
}
なので、絶対に==true/false
は書かないでください。
むしろ、if (res)
の方がだいぶマシだと思います。if文はブールタイプを必要とするため、カッコの中身は必ずToBoolean
の操作が行われます。ToBoolean
は上記のフォルシー値以外全部true
なので、間違いしにくいやり方かと。
23/04/23 追記
NaN === NaN
の結果はtrue
ではなく、false
となっています。これはタイプ強制変換と関係せず(すでにprimitiveのnumber
なので)、単純にNaN
自身の定義によるものです。Not a numberは実はエラーを意味しており、自分自身も含めてイコール判定は全てfalse
となります。
それで、===
を使えば大丈夫、との甘い考えはここで幻滅します。この問題も解決するために、Object.is(a,b)
で判定するしかありません。このメソッドは、js内部で2つの値が全く一緒かどうかをチェックするときに使うので、実は===
よりも正確なのです。0 === -0
も似たようなエッジケースとなります。NaN
と符号付きの0
以外、Object.is()
は===
と同じとなります。
詳細はこちらにも参考。
string|number|symbol
なのか
tsのオブジェクトキーのタイプはなぜこの例は2.9のリリースノートにあります。
単刀直入でいうと、symbol
は別扱いとして、キーは数字で入れても、文字列に強制変換されるからです。詳細はこちらのスペックに記載されています。
Properties are identified using key values. A property key value is either an ECMAScript String value or a Symbol value. All String and Symbol values, including the empty String, are valid as property keys. A property name is a property key that is a String value.
An integer index is a String-valued property key that is a canonical numeric string and whose numeric value is either +0𝔽 or a positive integral Number ≤ 𝔽(2^53 - 1). An array index is an integer index whose numeric value i is in the range +0𝔽 ≤ i < 𝔽(2^32 - 1).
数字を使おうと、最終的に文字列に変換するので、通常tsでオブジェクトを定義するときに、Record<string,any>
とか、[key:string]:any
とかの形にするのが多いです。
この変換を例で見ると:
const myObj = {}
myObj[true] = 111
myObj[undefined] = 222
myObj[{a:1}] = 333
myObj[[]] = 444
console.log(myObj) // { "true": 111, "undefined": 222, "[object Object]": 333, "": 444 }
終わりに
JSのタイプ強制変換は、この言語を理解するために不可欠な部分となります。今回はその仕様について、例を見ながら検証・まとめてみました。
冒頭にも触れましたが、タイプの強制変換は顕在的か暗黙的かは経験によって多少主観的な問題になりそうですが、そこにあまり気にせず、やはり重要なのはどこで変換が行われるか、その変換のルールへの理解だと思います。そこを押さえておけば、ほとんどの「サプライズ」を避けられるでしょう
最後は一緒に冒頭の問題解答を見てみましょう。
console.log(!!'false') // ToBoolean on string, not empty string, thus true
console.log('3' - 1) // hint is number, thus 3 - 1 = 2
console.log(1 + '') // hint is default(number), but coerced to string, thus '1'
console.log( +{} ) // hint is number, valueOf returns {}, thus call toString and returns '[object Object]', coerced to NaN
console.log( +[] ) // hint is number, valueOf returns [], thus call toString and returns '', coerced to 0
console.log([[]] + 1) // hint is default(number), valueOf returns [[]], toString returns '', '' + 1, hint is string, coerced 1 to string '1'
console.log([{}] + 1) // hint is default(number), valueOf returns [{}], toString returns '[object Object]', '[object Object]' + 1 coerced 1 to string, thus becomes to '[object Object]1'
console.log([] + {}) // hint is default(number), [] => '', {} => '[object Object]', results in '[object Object]'
console.log({} + {}) // hint is default(number), {} => '[object Object]', results in '[object Object][object Object]'
console.log({} - {}) // - can only operate on numbers, hint is number, {} => NaN, NaN - NaN = NaN,
補足:[[[1]]] + 1
とか見かけたりしますが、配列のtoString
を呼び出す時にjoin
が行われて、[[1]]
が取り出されて、さらにtoString -> join
の連続なので、最終的に'1'
だけとなります。なので何重の[]
があっても同じです。
ではでは。
(上記の内容について何か間違いや問題点などがありましたら、ご指摘やコメントをいただけると大変嬉しく存じます)
Discussion