型強制と等価比較アルゴリズム(==の抽象比較手続き)
なぜ 0 と空文字は等しいのに 0 と 0 の文字列とで結果が変わるのか、その理由が仕様レベルでつかめる。ToPrimitive と緩い等価の手続きを順を追って解き明かします。
- 1.緩い等価 == は型が違うと「片方を数値などへ変換してから比較」する。この変換規則を ECMAScript 仕様の IsLooselyEqual(抽象的等価比較)が一手ずつ規定している。
- 2.オブジェクトは ToPrimitive で原始値へ畳まれ、その内部で valueOf / toString と Symbol.toPrimitive が呼ばれる。null と undefined は互いにだけ等しく、それ以外とは == でも一致しない。
- 3.=== は変換せず型と値で比較、SameValueZero(includes など)は +0 と -0 を等しく扱う点だけが異なる。NaN の扱いも three-way で結果が分かれる。
「==は危ない」の正体は、決定的なアルゴリズム
JavaScript の緩い等価 == は「予測不能」と敬遠されがちですが、実際には ECMAScript 仕様が一手ずつ定めた完全に決定的な手続きです。0 == "0" が true で 0 == "" も true、しかし "0" == "" は false――この一見矛盾した結果も、仕様の手順を追えば一意に説明できます。本記事は、型変換の抽象操作(ToPrimitive / ToNumber / ToString)と、それを呼び出す緩い等価の比較手続き、そして === や SameValueZero との違いを仕様レベルで解剖します。基礎は JavaScript を前提にします。
土台となる抽象操作:ToPrimitive / ToNumber / ToString
緩い等価を理解するには、まずその部品である**抽象操作(abstract operation)**を押さえる必要があります。抽象操作は仕様内部の関数で、言語からは直接呼べませんが、== や + などの演算が裏で呼び出します。
ToPrimitive(input, hint) はオブジェクトを原始値(プリミティブ)へ畳む操作です。hint は "number" / "string" / "default" のいずれかで、まず input[Symbol.toPrimitive] があればそれを呼び、なければ hint に応じて valueOf と toString を順に試します。
hintが"number"または"default":valueOf()→toString()の順で、最初に原始値を返したものを採用。hintが"string":toString()→valueOf()の順。- どちらも原始値を返さなければ
TypeError。
== はオブジェクトを変換する際、hint を "default" として ToPrimitive を呼びます。つまり通常のオブジェクトでは valueOf() が先で、Object.prototype.valueOf は this 自身(オブジェクト)を返すため原始値にならず、結局 toString() が使われます。
const obj = { valueOf() { return 42; }, toString() { return "hello"; } };
obj == 42; // true … hint="default" なので valueOf() の 42 が採用される
String(obj); // "hello" … String() は hint="string" なので toString()
ToNumber(value) は原始値を数値へ変換します。undefined → NaN、null → +0、true → 1、false → +0、文字列は数値リテラルとして解釈(空白のみや空文字列は +0、解釈不能なら NaN)。Symbol と BigInt の変換規則は別途定義され、Symbol を ToNumber すると TypeError です。
| 元の値 | ToNumber の結果 | 補足 |
|---|---|---|
| undefined | NaN | 数値化できない |
| null | +0 | 空文字列と同じ +0 |
| true / false | 1 / +0 | 真偽値は 1 と 0 |
| ""(空文字列) | +0 | 空白のみの文字列も +0 |
| " 12 " | 12 | 前後空白は無視して解釈 |
| "0x1F" / "1e3" | 31 / 1000 | 16進・指数も数値リテラルとして解釈 |
| "abc" | NaN | 数値として読めない |
ToPrimitive の hint はあくまで「どちらを先に試すか」の優先順位です。Symbol.toPrimitive を実装すれば hint を受け取って自由に分岐でき、Date は唯一 hint="default" を "string" として扱う標準オブジェクトです。つまり同じ == でも Date だけ文字列寄りに振る舞う、という例外がここに由来します。
緩い等価 ==(IsLooselyEqual)の比較手続き
緩い等価の本体は、仕様の IsLooselyEqual(x, y)(旧称 Abstract Equality Comparison)です。両辺の型を見て、型が同じなら厳密等価へ、違えば段階的に変換します。要点を疑似コードにすると次のとおりです。地の文では集合記法を {number, string} のようにインラインコードで書きます(裸の波括弧は MDX が壊すため)。
IsLooselyEqual(x, y):
// 1) 型が同じなら厳密等価に委譲(変換なし)
if Type(x) == Type(y): return IsStrictlyEqual(x, y)
// 2) null と undefined は“互いにだけ”等しい
if (x is null and y is undefined): return true
if (x is undefined and y is null): return true
// 3) 数値 と 文字列:文字列を ToNumber して数値同士で比較
if Type(x)=number and Type(y)=string: return IsLooselyEqual(x, ToNumber(y))
if Type(x)=string and Type(y)=number: return IsLooselyEqual(ToNumber(x), y)
// 4) 真偽値は必ず ToNumber して比較(true→1, false→0)
if Type(x)=boolean: return IsLooselyEqual(ToNumber(x), y)
if Type(y)=boolean: return IsLooselyEqual(x, ToNumber(y))
// 5) 一方がオブジェクト、他方が原始値なら ToPrimitive(obj) して再比較
if Type(y) in {number,string,bigint,symbol} and Type(x)=object:
return IsLooselyEqual(ToPrimitive(x), y)
// (対称のケースも同様)
// 6) BigInt と数値/文字列は数学的な値で比較
// 7) いずれにも当てはまらなければ false
return false
この手続きから、よく挙げられる「奇妙な結果」がすべて導けます。鍵は == がほぼ常に数値へ寄せることです。
0 == "0"; // true … "0" を ToNumber → 0、0 == 0
0 == ""; // true … "" を ToNumber → +0、0 == 0
"0" == ""; // false … 両方とも文字列なので変換なし、文字列として "0" ≠ ""
false == ""; // true … false→0、""→0
null == 0; // false … null は手順2でのみ等しく、数値化されない
[] == false; // true … [] は ToPrimitive→""、false→0、""→0 で 0==0
null == 0 が false なのが要注意です。手順2で null と undefined の相互比較を特別扱いした後、null はそれ以降の手順で数値へ変換されません。だから null は null・undefined 以外の何とも == で一致しません。
== は推移律(a==b かつ b==c ならば a==c)を満たしません。"" == 0 は true、0 == "0" も true ですが、"" == "0" は false です。途中で型が変わると比較の土俵が変わるためで、これが == を脆くする本質です。実務では原則 === を使い、x == null(null と undefined をまとめて判定する慣用句)だけ例外的に許容する、という運用が安全です。
===(厳密等価)と SameValue / SameValueZero
===(IsStrictlyEqual)は変換を一切行いません。型が違えば即 false、同じ型なら値を比較します。ただし数値の比較で2つの罠があります。NaN === NaN は false(NaN は自分自身と等しくない)、+0 === -0 は true(符号付きゼロを区別しない)です。
仕様には等価判定の操作がもう2つあります。SameValue と SameValueZero です。これらは演算子ではなく、配列メソッドや Object.is など各 API が内部で使い分けます。
| 比較 | 型変換 | NaN とNaN | +0 と -0 | 代表的な利用先 |
|---|---|---|---|---|
| ==(緩い) | する | false | 等しい | —(非推奨) |
| ===(厳密) | しない | false | 等しい(区別しない) | 通常の比較 |
| SameValueZero | しない | 等しい | 等しい | includes / Map / Set のキー |
| SameValue | しない | 等しい | 区別する(異なる) | Object.is |
型変換をしない3つの比較(===・SameValueZero・SameValue)の違いは、NaN と符号付きゼロの2点だけに集約されます。=== は NaN を不一致にする一方で +0/-0 を同一視。SameValueZero は NaN を一致させつつ +0/-0 も同一視。SameValue は NaN を一致させ、かつ +0/-0 を区別します。
NaN === NaN; // false
[NaN].includes(NaN); // true … includes は SameValueZero
[NaN].indexOf(NaN); // -1 … indexOf は === なので見つからない
Object.is(NaN, NaN); // true … SameValue
Object.is(+0, -0); // false … SameValue は符号付きゼロを区別
+0 === -0; // true
new Set([+0, -0]).size; // 1 … Set のキーは SameValueZero
indexOf が === ベースで NaN を見つけられないのに対し、後発の includes が SameValueZero を採用して NaN を見つけられる――この差は丸暗記ではなく「どの比較述語を使っているか」で説明できます。Map/Set のキー一致も SameValueZero なので、NaN をキーにしても1つにまとまり、+0 と -0 は同じキー扱いになります。
<input> の値は常に文字列です。age == 18 のような緩い比較で数値と暗黙比較すると ToNumber が走り、"18" は通っても "18歳" は NaN になって意図せず false になります。比較前に Number() で明示変換し、=== で比べるのが堅実です。入力検証の設計は フォームバリデーション を参照してください。
仕様を読むときの勘所
ToPrimitive がユーザー定義の valueOf/toString/Symbol.toPrimitive を呼ぶということは、== の評価中に任意のコードが走り得ることを意味します。副作用のあるオブジェクトを == の両辺に置くと、比較のたびにメソッドが呼ばれて状態が変わる、という落とし穴があります。これは抽象操作が「内部関数の連鎖」であり、その末端がユーザーコードに接続しているためです。エンジンがこの変換をどう最適化するか(隠しクラスやインラインキャッシュ)は JavaScriptエンジンの内部 で扱います。
もう1点、== の手順5以降は再帰呼び出しになっている点に注意します。オブジェクトを ToPrimitive で原始値にした後、その原始値と相手をもう一度 IsLooselyEqual に通すため、[] == ![] のような式は「![]→false→ToNumber→0」「[]→ToPrimitive→""→ToNumber→0」と二段で畳まれて true になります。仕様を「再帰的に値を畳んでいく手続き」として読むと、難解に見える式も機械的に追えます。
まとめ
緩い等価 ==(IsLooselyEqual)は、型が同じなら厳密等価へ委譲し、違えば ToNumber を軸に片側を変換してから再帰的に比較する決定的アルゴリズムです。null と undefined は互いにだけ等しく数値化されません。オブジェクトは hint="default" の ToPrimitive(valueOf → toString、または Symbol.toPrimitive)で原始値へ畳まれます。=== は変換せず、NaN を不一致・+0/-0 を同一視。SameValueZero(includes・Map・Set)は NaN を一致させ、SameValue(Object.is)はさらに +0/-0 を区別します。違いは「型変換の有無」と「NaN・符号付きゼロの扱い」に集約され、実務では === を基本に、x == null だけ慣用句として使うのが安全です。
Web/フロントエンド Article
型強制と等価比較アルゴリズム(==の抽象比較手続き)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
オブジェクトは ToPrimitive で原始値へ畳まれ、その内部で valueOf / toString と Symbol.toPrimitive が呼ばれる。null と undefined は互いにだけ等しく、それ以外とは == でも一致しない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / 型強制」に近いか確認する。
- 強みである「緩い等価 == は型が違うと「片方を数値などへ変換してから比較」する。この変換規則を ECMAScript 仕様の IsLooselyEqual(抽象的等価比較)が一手ずつ規定している。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。