JavaScriptでは、オブジェクトからプリミティブへの暗黙の変換が発生することがあります1。その結果、例えば次のような楽しい事態が生じます。
console.log(["foo", "bar"] == "foo,bar"); // true
console.log([""] == 0); // true
console.log((123 ^ {}) === 123); // true
const obj1 = ["😂"];
const obj2 = ["😂"];
console.log(obj1 == "😂", "😂" == obj2); // true true
console.log(obj1 == obj2); // false
このような挙動は面白いので、Twitterとかで誰かが話題にするたびに多少は話題になります。しかしいい加減飽きたので、皆さんにはこんなの常識として理解しておいていちいち騒がないでいただきたく、この記事を用意しました。
この記事では、JavaScriptにおけるプリミティブ変換に関する仕様を余すこと無く紹介します。JavaScriptの経験が無い人には細部の理解が難しいかもしれませんが、それでも雰囲気くらいは掴めるのではないかと思います。
※本記事の内容は執筆時点の仕様(ES2017)に基づくものです。
明示的な変換
まず手始めに、オブジェクトをプリミティブに変換する明示的な方法を紹介します。オブジェクトは、文字列・数値・真偽値の3種類のプリミティブに変換することができます。そのために、String
, Number
, Boolean
というビルトイン関数を使用します。
console.log(String({foo: "bar"})); // "[object Object]"
console.log(String(["12", 345])); // "12,345"
console.log(Number({foo: "bar"})); // NaN
console.log(Number(["123"])); // 123
console.log(Boolean({foo: "bar"})); // true
console.log(Boolean(["0"])); // true
この例がなぜこのような結果になるのかは、後々解説します。
暗黙の変換
この記事では、上で紹介した方法以外は暗黙の変換として扱うことにします。オブジェクトからプリミティブへの暗黙の変換は、色々な場面で発生します。
一つは、==
演算子でオブジェクトとプリミティブを比較した場合です。例えば["foo", "bar"] == "foo,bar"
という比較を行った場合、左辺がオブジェクト(配列)、右辺が文字列なので、オブジェクトが文字列に変換されます。この場合左辺の配列が"foo,bar"
という文字列に変換されるので比較の結果はtrueになります。
ちなみに、JavaScriptを書く読者の方は恐らくご存知の通り、==
演算子はJavaScriptにおいてはたいへん悪名高い演算子です。その理由はまさにこの暗黙の型変換を行うからです。より安全な比較演算子である===
は暗黙の型変換を行いません。オブジェクトと文字列を比較した時点で異なる値として扱われ、結果はfalse
となります。
また、-
や^
など、数値同士の演算を行う演算子をオブジェクトに適用した場合も、やはりオブジェクトが数値に変換されます。例えば["123"] - ["12"]
という式は両辺がオブジェクト(配列)ですが、左辺が123
、右辺が12
に変換されるため、結果は111
という数値になります。
他にはプロパティ名として使用した場合(obj[ ["123"] ]
)やテンプレート文字列中に出現した場合(`123${obj}9`
)、Number.parseInt
の引数として与えられた場合など、さまざまな場面でオブジェクトはプリミティブに変換されます。
全ての場合を列挙するのは大変なので、本当に完全に理解したいという方は仕様書を"ToPrimitive", "ToString", "ToNumber"などで全文検索してください。
プリミティブへの変換の種類
実は、オブジェクトからプリミティブへの変換は3種類あります。これは変換時に付随するhint値というパラメータによって区別されます。hint値には、変換後のプリミティブとして文字列が期待されていることを表すstring
と、数値が期待されていることを表すnumber
と、特に期待する型がないdefault
の3種類があります。
例えば、obj * 10
という式でobj
がオブジェクトだった場合を考えます。*
は算術演算子でありオペランドとして数値が期待されますから、obj
は数値に変換される必要があります。よって、obj
はhint値がnumber
でプリミティブに変換されます。
同様に、JavaScriptではプロパティ名は文字列(またはシンボル)ですから、プロパティのキーとしてオブジェクトを使用した場合は、オブジェクトは文字列に変換されることが期待されるため、hint値がstring
でプリミティブに変換されます。
hint値default
は、==
演算子と+
のオペランドにオブジェクトが来た場合に使用されます。これらの演算子は文字列と数値の両方を取ることができるためです。
プリミティブへの変換の挙動
以上の説明で、どのような場合にオブジェクトがプリミティブに変換されるのか、そしてプリミティブへの変換には実は3種類あることが分かりました。ではここから、オブジェクトからプリミティブへの変換が発生した場合に具体的にどのような挙動が起こるのかを解説します。仕様書読めるよという方は(そんな人はこの記事を読まないとは思いますが)、ToPrimitiveのことです。
オブジェクトのプリミティブへの変換は、toString, valueOfという2つのメソッドを呼び出すことで行われます。具体的には、hint値がstring
の場合、すなわち文字列への変換が期待されている場合にはオブジェクトのtoStringメソッドを呼び出し、それ以外の場合(hint値がnumber
かdefault
の場合)はvalueOfメソッドを呼び出します。もちろん、メソッドの返り値がプリミティブへの変換結果として使用されます。
例えば{}
を文字列に変換すると"[object Object]"
になるのは、Object.prototype.toString
の定義によるものです2 。独自のtoStringメソッドを持ったオブジェクトを作ることにより、文字列に変換したときの挙動をカスタマイズできます。
var obj = {
toString() {
return "world";
}
};
console.log(`Hello, ${obj}!`); // "Hello, world!"
一方で、数値への変換(及びデフォルトの場合)はvalueOfメソッドが担当することになります。よって、valueOfメソッドをカスタマイズしたオブジェクトを作ると、数値に変換されたときの挙動を制御できます。
var obj = {
valueOf() {
return 123;
}
};
console.log(obj * 10); // 1230
console.log(obj == 123); // true
変換失敗時のフォールバック
ところで、一般のオブジェクトのvalueOfの実装(すなわちObject.prototype.valueOf
)は、概ね次のような感じです(厳密にはちょっと異なりますが)。
valueOf() {
return this;
}
ふざけてんのかと言いたくなりますね。プリミティブに変換しようという気概がまったく感じられません。このように、toStringやvalueOfの返り値というのは必ずしもプリミティブになるとは限りません。
このような場合、プリミティブへの変換に失敗したと見なされます。そして、その場合はもう一方のメソッドを試すというフォールバックの挙動が起こります。すなわち、valueOfでプリミティブに変換しようとして失敗した場合、次にtoStringを試すのです。先にtoStringを試して失敗した場合も、やはり次にvalueOfを試します。
var obj = {
toString() {
return 100;
},
valueOf() {
return this;
},
};
console.log(obj * 123); // 12300
上の例では、obj * 123
なのでobj
をまずvalueOfでプリミティブに変換しようとします。しかし、valueOfの返り値はオブジェクトなのでプリミティブへの変換に失敗します。なので、次にtoStringが呼び出され、その返り値が変換結果として採用されます。今回のオブジェクトはtoStringが数値を返すというなんとも厄介なオブジェクトですが、これによりobj
を数値に変換した結果が100
となります。
また、そもそもtoStringやvalueOfメソッドが存在しない(あるいは関数でない)場合も失敗扱いとなり、フォールバックが発生します。
var obj = {
toString: null,
valueOf() {
return "foobar";
},
};
console.log(String(obj)); // "foobar"
この例ではobj
を文字列に変換しようとしていますが、toStringがnullになっているためvalueOfにフォールバックし、valueOfの返り値である"foobar"
が変換結果となります。
そして、toStringもvalueOfも両方失敗した場合はプリミティブへの変換が失敗したということになり、エラーが発生します。
var obj = {
toString: null,
};
console.log(obj == 3); // TypeError
普通のオブジェクトのvalueOfはもともと失敗するので、toStringを無効化してやるとプリミティブへの変換に失敗するオブジェクトを作ることができます。
プリミティブ型どうしの変換
先ほど、toStringの返り値が数値になる変なオブジェクトを作りました。hint値がstring
でオブジェクトをプリミティブに変換した場合はtoStringが優先されます。そして、toStringの返り値が何らかのプリミティブであれば、それが文字列でなかったとしてもプリミティブへの変換は成功として扱われます。ということは、hint値がstring
でオブジェクトをプリミティブに変換した場合でもその結果が文字列とは限らないわけです。
どうしても文字列に変換したい場合(仕様書用語でいうとToStringで変換した場合)は、得られたプリミティブがさらに文字列に変換されます。どうしても数値が欲しい場合も同様です。
var obj = {
toString() {
return true;
}
};
console.log(String(obj)); // "true"
この例は、toStringメソッドがtrue
を返すふざけたメソッドです。String
による明示的な変換では結果はかならず文字列になる必要がありますから、obj
をプリミティブに変換した結果として得られたtrue
はさらに文字列に変換されて"true"
となります。
数値へ変換する場合も同様です。
var obj = {
toString() {
return "123";
},
};
console.log(Number(obj)); // 123
この例ではobj
を数値に変換した結果が123
となっています。上で解説したように、オブジェクトを数値に変換したい場合はvalueOfがまず呼ばれますがそれは失敗し、toStringの結果が用いられます。toStringの結果は"123"
という文字列ですが、今回は数値が欲しいのでこの文字列が数値に変換され、123
が結果となります。
プリミティブへの変換の結果が尊重される場合
大抵の場合、オブジェクトをプリミティブに変換する場合は特定の型(文字列とか数値とか)が目当てなので、前節で説明したようにオブジェクトをプリミティブに変換した結果が目的の型とあわない場合はさらに目的の型に変換されます。
しかし、オブジェクトをプリミティブに変換した結果が尊重される場合が少数あります。それは、+
により変換される場合、==
や<
などの比較演算子で変換される場合、Dateコンストラクタにオブジェクトを渡した場合、そしてプロパティ名として使用する場合です。これらの演算子等は複数の種類のプリミティブ(文字列・数値)を扱うことができるためこのような挙動になっています。
+
による変換
+
はどちらか一方のオペランドが文字列の場合は文字列の結合となり、そうでない場合は数値の加算となります。オペランドにオブジェクトが渡されたときの挙動は、とりあえずオブジェクトをプリミティブに(hint値default
で)変換して、その結果どちらか一方が文字列なら文字列の結合となります。
var obj1 = {
toString() {
return "123";
},
};
var obj2 = {
valueOf() {
return 123;
}
};
var obj3 = {
toString() {
return true;
}
};
console.log(obj1 + 321); // "123321"
console.log(obj2 + 321); // 444
console.log(obj3 + 321); // 322
この例では、obj1
をhint値default
でプリミティブに変換すると"123"
となるため+
は文字列の結合となり、結果は"123321"
となります。一方obj2
は数値123
に変換されるため+
は数値の加算となり、結果が444
となります。obj3
の変換結果は真偽値true
ですが、この場合+
は数値の加算となるためtrue
が数値に変換されて1
となり、結果が322
になります。
==
による変換
比較演算子==
の場合は少し挙動が複雑です。まず、オブジェクト同士を比較した場合はプリミティブへの変換は起こりません。===
と同様に同じオブジェクトの場合のみtrueになります。プリミティブへの変換が起こるのは、オブジェクトとプリミティブの比較をした場合です。この場合オブジェクトはhint値default
でプリミティブに変換され、両辺が文字列の場合のみ文字列の比較となります。片方でも数値や真偽値がある場合は数値の比較となります。
var obj1 = {
toString() {
return "hello";
},
};
var obj2 = {
toString() {
return "1e3";
}
};
console.log(obj1 == "hello"); // true
console.log(obj2 == "1000"); // false
console.log(obj2 == 1000); // true
この例では、obj1
と"hello"
を比較しようとしているのでobj1
がプリミティブに変換されて"hello"
になり、文字列の比較により結果はtrueとなります。obj2
と"1000"
を比較する場合も同様に文字列の比較となり、"1e3"
と"1000"
は違う文字列なのでfalseとなります。
興味深いのは最後の比較ですね。この場合obj2
と1000
を比較しようとしているのでまずobj2
がプリミティブに変換され"1e3"
と1000
の比較になります。文字列と数値の比較なので左辺の文字列は数値に変換されます。実は"1e3"
という文字列は数値に変換すると1000
になるため、この比較はtrueとなります。
その他の比較演算子による変換
<
などの比較演算子の場合も同様に、両辺が文字列の場合のみ文字列の比較となり、それ以外の場合は数値の比較となります。==
の場合と異なるのは、両辺がオブジェクトの場合でもプリミティブへの変換が行われることと、その際のhint値はnumber
となることです。
プロパティ名として使用する場合の変換
オブジェクトをプロパティ名として用いる場合はやや特殊な挙動を示します。それは、プロパティ名としては文字列だけでなくシンボルを使用することができるからです。
オブジェクトがプロパティ名として使用された場合、まずhint値string
でプリミティブに変換されます。結果が文字列またはシンボルの場合はそれが採用され、それ以外の場合は結果が文字列に変換されます。
var symb = Symbol();
var obj = {
toString() {
return symb;
}
};
var obj2 = {
[symb] : "hi"
};
console.log(obj2[obj]); // "hi"
しかし、プリミティブに変換するとシンボルになるオブジェクトは扱いにくいです。シンボルは文字列や数値に変換できないからです。
var symb = Symbol();
var obj = {
toString() {
return symb;
}
};
console.log(`Hello, ${obj}!`); // TypeError
[Symbol.toPrimitive]
メソッド
ここまでで、オブジェクトをプリミティブに変換する場合はtoString, valueOfメソッドが使用されることや、それに関連する挙動を紹介しました。
しかし、ES2015以降では、オブジェクトからプリミティブへの変換をより細かに制御する方法があります。それが、[Symbol.toPrimitive]
メソッドを使う方法です。聡明な読者の皆さんは、ここまで読んである問題に気がついたはずです。それは、プリミティブへの変換時のhint値にはstring
, number
, default
の3種類があるのに、number
とdefault
は挙動の違いが無いという点です。[Symbol.toPrimitive]
を使うことで、この2つに異なる挙動を与えることができます。
プリミティブに変換しようとしているオブジェクトに[Symbol.toPrimitive]
メソッドが存在する場合、toStringやvalueOfメソッドを呼ぶ代わりにこのメソッドを呼び、その返り値がプリミティブへの変換の結果として採用されます。その際第1引数にhint値が文字列で与えられます。
var obj = {
[Symbol.toPrimitive](hint) {
if (hint === "string") {
return "world";
} else if (hint === "default") {
return 100;
} else {
return true;
}
},
};
console.log(`Hello, ${obj}!`); // "Hello, world!"
console.log(obj == 100); // true
console.log(obj * 10); // 10
console.log(obj == "world"); // false
この例のobj
は、3種類のhint値に対して異なる値を返すというはた迷惑なオブジェクトです。obj == "world"
についてはfalseとなる点に注意してください。==
は常にhint値defaultでオブジェクトをプリミティブに変換するからです。
ちなみに、[Symbol.toPrimitive]
がプリミティブ以外を返した場合(すなわちオブジェクトを返した場合)、プリミティブへの変換は失敗とみなされエラーが発生します。
var obj = {
[Symbol.toPrimitive]() {
return this;
},
};
console.log(obj == 100); // TypeError
組み込みオブジェクトを変換した場合の挙動
この記事の冒頭の例では配列が多く出てきました。これは配列をプリミティブに変換したときの挙動がちょっと特殊だからです。
基本的には、組み込みオブジェクトをプリミティブに変換したときの挙動は、これまで紹介したtoString, valueOf, あるいは[Symbol.toPrimitive]
によって制御されています。例えば、配列をプリミティブに変換したときの挙動はArray.prototype.toString
により定義されています。Array.prototype.toString
の挙動はだいたいこうです。
toString() {
return this.join();
}
すなわち、配列の場合はtoString
はjoin
と同じということです。join
は配列の各要素をつなげた文字列を返すメソッドであり、区切り文字列を省略した場合は","
となります。よって、例えば["foo", "bar"]
を文字列に変換した場合は"foo,bar"
となるのです。
なお、Array.prototype.valueOf
は定義されていないので、Object.prototype.valueOf
と同じく自分自身を返します。
配列の場合もtoStringによりプリミティブに変換されるということは、自分でtoStringを定義することでプリミティブへの変換がカスタマイズされた配列を作れるということです。
var obj = ["foo", "bar"];
obj.toString = ()=> "world";
console.log(`hello, ${obj}!`); // "hello, world"
多くの組み込みオブジェクトにおいて、こんな感じにtoStringがいい感じに定義されていたりいなかったりします。その中でひときわ異彩を放つのがDateオブジェクトです。
Dateオブジェクトは独自のtoStringとvalueOf(すなわちDate.prototype.toString
とDate.prototype.valueOf
)を持っています。toStringは何かいい感じの文字列を返し、valueOfは現在の時刻を表す数値(1970年1月1日からの経過時間をミリ秒で表した数値)を返します。
まず、ちゃんとvalueOfが数値を返すようになっているのがいいですね。これにより、Number(date)
のようにして数値に変換した際にちゃんと意味のある数値になります。また、Dateオブジェクトをhint値default
でプリミティブに変換するときの挙動が少し特殊で、この場合はtoStringを先に試します。一般のオブジェクトがhint値default
の場合にはvalueOf
を先に試すのとは対称的です。この挙動により、+
で文字列とDateオブジェクトを結合したときにDateオブジェクトが数値でなく文字列になるようになっています。
var date = new Date();
console.log("現在の時刻は " + date + "です"); // "現在の時刻は Wed May 09 2018 11:55:41 GMT+0900 (JST)です"
ちなみに、Dateオブジェクトのこの特殊な挙動はDate.prototype[Symbol.toPrimitive]
により定義されています。
[Symbol.toStringTag]
について
ところで、普通のオブジェクトをtoStringでプリミティブに変換すると[object Object]
になります。実は後半のObject
の部分はものによって変わります。例えばPromiseオブジェクトの場合は[object Promise]
となります。
var p = Promise.resolve(123);
console.log(String(p)); // "[object Promise]"
この部分を決めているのが[Symbol.toStringTag]
プロパティです。これをカスタマイズしたオブジェクトを作ることで、Object.prototype.toString
の結果を部分的にカスタマイズしたオブジェクトを作ることができます。
var obj = {
[Symbol.toStringTag] : "Hello",
};
console.log(String(obj)); // "[object Hello]"
正直、だからどうしたという感想しか出てきませんね。組み込みオブジェクトを文字列に変換したときの結果を制御するために一役買っていますが、我々が使うことはあまりないかもしれません。文字列に変換したときの結果を手軽にカスタマイズしたいときに使ってください。一応、クラスを定義するときに次のように[Symbol.toStringTag]
をカスタマイズしておくと、そのクラスのインスタンスを文字列に変換したときの結果を制御できていい感じです。
class Hello {
get [Symbol.toStringTag]() {
return "Hello";
}
}
var obj = new Hello();
console.log(String(obj)); // "[object Hello]"
なお、当然ながら、これはObject.prototype.toString
の処理に使われるものなので、toString
自体をカスタマイズしたり[Symbol.toPrimitive]
を使用したりした場合はほぼ無意味になります(Object.prototype.toString.call(obj)
のように呼ばれたら使われるので完全に無意味ではありませんが)。
応用
お疲れさまでした。以上で、JavaScriptにおけるオブジェクトからプリミティブへの変換の解説は終わりです。最後に、このような挙動から分かる少し面白い例を紹介します。
冒頭の例について
今までの説明で、この記事の冒頭の例はほぼ分かると思います。例えば、[""] == 0
がtrueとなるのはなぜでしょうか。これは次のように評価されます。
- オブジェクトとプリミティブの比較なので、左辺の
[""]
がhint値default
でプリミティブに変換され、その結果"" == 0
という比較になる。 - 文字列と数値の比較なので、左辺の文字列が数値に変換される。
""
を数値に変換すると0
なので0 == 0
という比較になる。 - 数値同士の比較は普通に値が等しい場合にtrueになるので、結果はtrueとなる。
このように、オブジェクトを数値に変換する場合、度々オブジェクト → 文字列 → 数値という2段階の変換になります。
(123 ^ {}) === 123
についても解説が必要かもしれません。^
はビットごとのXORです。
実は^
のようなビット演算は少し特殊で、当然ながらオペランドは数値に変換されますが、それだけでなくこれらは32ビット整数になります。というのも、JavaScriptは数値側は(もうすぐ追加されるBigIntを除けば)1種類で、64bit浮動小数点数のみです。そのため、ビット演算を行うときはまず数値を32ビット整数の範囲に落としこむ必要があるのです。このことを念頭におくと、123 ^ {}
は次のように評価されることが分かります。
-
123 ^ {}
は数値の演算なので、右辺の{}
を数値に変換したい。 -
{}
をhint値number
でプリミティブに変換すると"[object Object]"
になる。 - 数値が欲しいので
"[object Object]"
を数値に変換すると、結果はNaN
になる。- 文字列数値に変換する場合、数値として解釈できない文字列は
NaN
になるという点に注意してください。
- 文字列数値に変換する場合、数値として解釈できない文字列は
-
NaN
を32ビット整数に変換すると0
になる。- 基本的に
NaN
を含む演算はNaN
になるのが浮動小数点数の演算の鉄則ですが、32ビット整数の世界にNaN
は無いため0
に変換されます。
- 基本的に
-
123 ^ 0
が評価されて123
になる。
この例が少し面白いところは、一般のオブジェクトを数値に変換しようとするとNaN
になってしまい面白くないところ、ビット演算をかませることで0
に引き戻すことができ、オブジェクトを交えたよくわからない演算ができるという点です。
2種類の文字列結合は等価ではない
変数の値を交えた文字列を作りたい場合に古くから使われてきた方法は+
による文字列結合です。すなわち、"こんにちは、 " + user + "さん"
のような処理です。一方、ES2015からは便利なテンプレート文字列が導入されたため、これを`こんにちは、${user}さん`
のように書くことができるようになりました。
この記事を読んだ皆さんならお分かりの通り、この2つは完全に等価ではありません。もちろん、user
が文字列のときは大丈夫です。しかし、user
がオブジェクトだった場合は違いが発生します。なぜなら、+
の場合オブジェクトはhint値がdefault
でプリミティブに変換されるのに対し、テンプレート文字列の場合はhint値string
で変換されるからです。
var user = {
toString() {
return "ジョン・スミス";
},
valueOf() {
return "メアリー・スー";
},
};
console.log("こんにちは、" + user + "さん"); // "こんにちは、メアリー・スーさん"
console.log(`こんにちは、${user}さん`); // "こんにちは、ジョン・スミスさん"
==
と<
で結果が食い違うオブジェクト
上で解説したように、==
のオペランドはhint値default
でプリミティブに変換され、<
などその他の比較演算子の場合はhint値number
でプリミティブに変換されます。ということは、この2つで異なる数値を返せば==
と<
で結果が食い違うオブジェクトを作れます。
var obj = {
[Symbol.toPrimitive](hint) {
return hint === "number" ? 0 : 100;
}
};
console.log(obj == 100); // true
console.log(obj < 100); // true
変換時に副作用があるオブジェクト
そもそも、オブジェクトからプリミティブへの変換時に関数が呼ばれるということは、副作用を仕込み放題ということです。少し前に話題になったStackOverflowの質問を覚えている方もいると思いますが、このように毎回プリミティブに変換した結果が違うオブジェクトを作ることができます。
var obj = {
value: 1,
valueOf() {
return this.value++;
},
};
console.log(obj == 1 && obj == 2 && obj == 3); // true
結論
いかがでしたか。JavaScriptにおけるオブジェクトからプリミティブへの変換はなかなか奥が深いものがあります。言うまでもなく、このような珍妙な挙動の裏には古くから続くJavaScriptの後方互換性を保とうとする意図があります。
実際のところ、ここで紹介したような挙動が問題となるのはレアなケースであり、ほとんどの場面で暗黙の変換は大した問題もなく動作するでしょう。しかし、==
や+
などを見るたびにこういった背景を思い浮かべながらコードを読むというのは労力の無駄というものです。ですから、できるだけプリミティブへの暗黙の変換は避けて安心して読めるコードを書くといいのではないかと思います。