🚀

JS基礎いろいろー型強制(type coercion)

2023/01/29に公開

経緯

今回の話にした理由というのは、先日社内に起こった会話でした。とある社員のキーボードの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のリターン値

UndefinedNullはそのまま””をつけて文字列になっています。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とかで検索してみても良いでしょう(こちらにも参考に)。注意したいのは、UndefinedNullが多くのところで結構似ている挙動になるが(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)だけではありません。他にもよく出会う強制変換が行われている箇所は以下となります。

  • !!!!aaをブールタイプへ変換してから反転する
  • if文の条件、if (...)カッコ内の値はブールタイプへ変換する
  • for i ループの2個目のループ条件、上記と同じ
  • whileループ(do whileも含む)の条件、上記と同じ
  • &&演算、a && bでは、aをブールタイプへ変換する
  • ||演算、上記と同じく
  • a ? b : c三項条件演算、aをブールタイプへ変換する

ToPrimitive

さて、JSのタイプを大きく分けると、primitiveとobjectの2種類に分けられます。つまり、オブジェクト({}[]Dateなどを含む)以外は全部primitiveタイプとなります。それで、ToPrimitiveの操作は、オブジェクトタイプの値を、基本タイプへの変換を指しています。

ただ、基本タイプには複数存在するし、変換先のタイプはどうやって決まるの、と疑問になるでしょう。ここも若干複雑なステップがありますが、整理してみると:

  1. @@toPrimitiveシンボルメソッドを実行、実行時に望ましい変更先のヒントはstringnumberかを確認
  2. もし望ましい変更先がstringの場合、変換の優先順位をToString -> ToNumberとし、逆の場合はToNumber -> ToStringとする
  3. stringへ変換する場合はtoString()メソッドを呼び出し、numberへ変換する場合はvalueOfを呼び出す
  4. toString()valueOf()のリターン値が基本タイプとなれば、その時点で変換終了
    1. 優先メソッドが失敗した場合は、候補メソッドの方を実行
    2. どれもダメだった場合(基本タイプの値に変換できず)は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

ToStringToNumberのテーブルの最後の行を思い出してください。この変換には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メソッドはすべての配列要素を文字列へ変換するため、nullundefined'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の値に実はタイプチェックでかなり有用typeofinstanceofよりも、勝手にこの値自体を上記のように編集しない限り、primitive/objectのタイプを全部カバーすることが可能。こちらの記事にも参考。

function getType(value) {
  return Object.prototype.toString.call(value)
    .replace(/^\[object (\S+)\]$/, '$1')
    .toLowerCase();
}

=====の違いは?

タイプの強制変換が最もよく見られるところとも言えよう。「とにかく==はやめろ」と言われたことはありませんか。

その違いは一言言えば、強制変換の有無だけです。==IsLooselyEqualの操作を意味しており、===の方はIsStrictlyEqualとなります。

大原則として、

  • 比較対象のタイプが一致すれば、IsLooselyEqualIsStrictlyEqualと同じ挙動となる
  • 比較対象のタイプが一致しない場合、numberを優先ヒントとして、タイプが一致するまで強制変換し続ける

スペックで示されているLooselyの場合の内容を抜粋すると、比較する二つの値にとって、

  1. タイプ一致の場合は、IsStrictlyEqualを実行
  2. nullundefinedの場合はtrue
  3. numberstringを比較する場合、stringToNumberで変換してから比較
  4. booleanが入っている場合、booleanToNumberで変換してから比較(つまり10となる)
  5. 一つの値がオブジェクトタイプ、もう一つが基本タイプの場合、オブジェクトタイプを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となります。すると、右側のfalse0と変換され、このタイミングでタイプ一致して結果が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()===と同じとなります。

詳細はこちらにも参考。

tsのオブジェクトキーのタイプはなぜstring|number|symbolなのか

この例は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