JavaScript入門:型・==と===・NaNをjscで実行して理解
JavaScript講座1。Mac標準のjscで5分で実行、let/const/varのエラー、typeof null、==と===の実行表、0.1+0.2、全角数字と消費税の切り上げの罠まで実測で解説。
シリーズ:JavaScript 講座 1/6(初級)。 これから初めて JavaScript を書く人、あるいはコピペで動かしてはいるものの "10" + 5 が "105" になる理由をまだ説明できない人向けです。読み終えると、ターミナルで5分以内に JavaScript を実行し、let・const・var を使い分け、初心者が必ず出会うエラーメッセージを読み、== が嘘をつく場面を見分けられるようになります。
業務で JavaScript を触り始めると、最初につまずくのは文法よりも「暗黙の型変換」です。フォームから来た値が文字列のまま足し算されて "105" になる、全角で入力された「10」が NaN になる、消費税を Math.ceil(price * 1.1) で切り上げたら 100 円の商品が 111 円になる。どれも例外が出ずに静かに間違った数字が流れるので、見つけたときにはもう請求書に載っています。この記事の出力はすべて Apple Silicon Mac(M1 Pro)の macOS 26.6 に標準で入っている JavaScriptCore(Safari のエンジン)のシェル jsc で実際に実行したものです。Node.js・Deno・ブラウザは実行していません。挙動が違う可能性があるところはその都度書きます。
5分で最初のプログラム
Mac には最初から JavaScript エンジンとそのコマンドラインシェルが入っています。PATH に入っていないので、まず短い名前を付けます。
$ alias jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc
$ jsc -e 'print(1 + 2)'
3
残したければ alias の行を ~/.zshrc に書いておきます。次に hello.js を作ります。
print("Hello, JavaScript!");
console.log("ブラウザや Node 向けの書き方");
実行するとこうなります。
$ jsc hello.js
Hello, JavaScript!
Exception: TypeError: undefined is not an object (evaluating 'console.log')
global code@hello.js:2:8
$ echo $?
3
最初のエラーです。一行ずつ読んでみます。
print()はjscシェルの出力関数です。ブラウザや Node.js ではconsole.log()を使います。- この
jscにはconsoleが存在しません(typeof consoleはundefinedでした)。つまりconsole.logは「undefinedのプロパティlogを読め」という意味になり、TypeError(型が合わない値に対する操作)になります。 global code@hello.js:2:8はスタックトレースで、ファイルhello.jsの2行目・8桁目という意味です。Exception は誰もエラーを捕まえなかったということで、プログラムは止まり、終了コードは 3 でした。
同じファイルを jsc とブラウザの両方で動かしたいなら、先頭に globalThis.console ??= { log: print }; を1行書けば済みます。この講座では素直に print を使います。ファイルを渡さずに jsc を起動すると対話モードになり(Ctrl+D で終了)、試しに jsc -i に流し込むと 0.1 + 0.2 は 0.30000000000000004、typeof null は "object" と表示されました。どちらも後で説明します。
let・const・var と、それぞれのエラー
変数は値に付ける名前です。今の JavaScript には2つの宣言方法と、古い方法が1つあります。
const TAX = 0.1; // 再代入できない
let count = 0; // 再代入できる
count = count + 1;
var legacy = 1; // 2015 年以前の書き方。新しいコードでは使わない
それぞれ失敗の仕方が違います。const に再代入すると、
$ jsc const.js # const TAX = 0.1; TAX = 0.2;
Exception: TypeError: Attempted to assign to readonly property.
global code@const.js:1:21
同じ名前の let を2回宣言すると、実行前に SyntaxError(そもそも JavaScript として正しくない)で止まります。
$ jsc redecl.js # let name = "Mina"; let name = "Ken";
Exception: SyntaxError: Cannot declare a let variable twice: 'name'.
at redecl.js:1
var だけは特殊で、宣言が巻き上げ(hoisting)られます。宣言の行より前から名前が存在し、中身は undefined です。let・const も巻き上げられますが、宣言前に読むとエラーです。
print(a); // undefined
var a = 1;
print(b); // エラー
let b = 2;
undefined
Exception: ReferenceError: Cannot access 'b' before initialization.
global code@hoist.js:3:8
let/const の行より前の区間を TDZ(Temporal Dead Zone、一時的デッドゾーン) と呼び、関数の中でどう効いてくるかは第2回で扱います。ReferenceError は「その名前は使えない」という意味で、ただのタイプミスでも出ます。print(totl) は ReferenceError: Can't find variable: totl でした。Node や Chrome では同じエラーが totl is not defined と表示されるので(ここでは未実行)、検索するときは両方で探してください。
逆に、デフォルトでは報告されないタイプミスがあります。
count = 5; // どこでも宣言していない
print(count, typeof globalThis.count); // 5 number
宣言していない名前に代入すると、黙ってグローバル変数が作られます。ファイルの1行目に "use strict"; を書けば、同じ代入が ReferenceError: Can't find variable: count になります。ES モジュールと class の中身は常に strict モードなので、最近のコードは多くの場合この保護を最初から受けています。
目安: 基本は const、値が本当に変わるときだけ let、新しいコードで var は使わない。
型と typeof
すべての値には型があります。JavaScript には7つのプリミティブ型(単純で変更できない値)と、オブジェクトがあります。typeof 演算子で型が分かりますが、有名な例外が2つあります。types.js の出力です。
42 number
3.14 number
hi string
true boolean
undefined undefined
null object
10n bigint
Symbol(id) symbol
{} object
[] object
function(){} function
NaN number
ポイントは次のとおりです。
- 整数も小数も1つの
number型(64 ビット浮動小数点数)です。2⁵³ を超える整数には別の型bigint(10nと書く)があります。 typeof nullは"object"です。最初期のバグが互換性のために残っているだけなので、null の判定はvalue === nullで行います。- 配列もオブジェクトなので
typeof []は"object"です。Array.isArray([])を使います(trueでした)。 NaN(Not a Number)の型はnumberです。計算に失敗した結果の「数値」だと考えると納得できます。undefinedは「一度も値が入っていない」、nullは「意図的に空」という意味です。API はどちらも返してくるので、後述の== nullが役に立ちます。
== と ===:実際に実行した比較表
===(厳密等価)は型を変換せずに比べるので、型が違えば必ず false です。==(等価)はまず両辺を一定の規則で変換してから比べます。下の組み合わせを両方の演算子で実行しました(eqtable.js)。
| a | b | a == b |
a === b |
|---|---|---|---|
0 |
"0" |
true | false |
0 |
"" |
true | false |
0 |
false |
true | false |
"0" |
false |
true | false |
"" |
false |
true | false |
"" |
"0" |
false | false |
null |
undefined |
true | false |
null |
0 |
false | false |
null |
false |
false | false |
undefined |
false |
false | false |
NaN |
NaN |
false | false |
[] |
0 |
true | false |
[] |
"" |
true | false |
[] |
false |
true | false |
"1" |
1 |
true | false |
2行目・5行目・6行目を見てください。0 == "" も 0 == "0" も true なのに、"" == "0" は false です。ゆるい等価は推移律すら成り立たないので、数学の「=」と同じ感覚では扱えません。[] == false が true なのは、空配列が "" に、さらに 0 に変換されるからです。
残す価値のあるゆるい比較は x == null だけです。null と undefined のときだけ true になり、それ以外では false です(7〜9行目)。「そもそもこの項目が送られてきたか」を見るのに便利です。ただし「常に ===」というリンタールールを単純に保つため、x === null || x === undefined と書くチームも多くあります。
| 知りたいこと | 書き方 | 避ける書き方 |
|---|---|---|
| 値も型も同じか | a === b |
a == b |
x が null か undefined か |
x == null または x ?? 既定値 |
!x(0 や "" も引っかかる) |
x が NaN か |
Number.isNaN(x) |
x === NaN(常に false) |
x が配列か |
Array.isArray(x) |
typeof x === "object" |
x が本当に null か |
x === null |
typeof x === "object" |
数値:0.1 + 0.2、NaN、そして消費税
print(0.1 + 0.2); // 0.30000000000000004
print(0.1 + 0.2 === 0.3); // false
print(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true
print((0.1 + 0.2).toFixed(2), typeof (0.1 + 0.2).toFixed(2)); // 0.30 string
print(7 / 2, Math.trunc(7 / 2), 7 % 2, -7 % 2); // 3.5 3 1 -1
print(1 / 0, -1 / 0, 0 / 0); // Infinity -Infinity NaN
print(Number.MAX_SAFE_INTEGER, 2 ** 53 + 1, 2n ** 53n + 1n);
// 9007199254740991 9007199254740992 9007199254740993
print(1.15.toFixed(1), 1.25.toFixed(1), 1.35.toFixed(1)); // 1.1 1.3 1.4
10進数で 1/3 が割り切れないのと同じで、0.1 は2進数で正確に表せないため小さな誤差が出ます。整数は Number.MAX_SAFE_INTEGER(2⁵³ − 1)までは正確ですが、それを1つ超えた 2 ** 53 + 1 は ...993 ではなく ...992 になりました。BigInt なら正しく計算できます。0 で割っても例外にはならず、Infinity か NaN になります。toFixed は文字列を返し、入力した10進数ではなく内部の2進数の値を丸めるので、1.15 は実は 1.149999… で 1.1 に、1.25 は 1.3 になります。
日本の業務で一番身近なのは消費税です。100 円に 10% をかけて切り上げてみます。
print(100 * 1.1, Math.ceil(100 * 1.1), Math.ceil(100 * 110 / 100));
// 110.00000000000001 111 110
100 * 1.1 がわずかに 110 を超えるので、Math.ceil で 111 円になります。1〜10,000 円の全価格で Math.ceil(p * 1.1) と整数で計算した Math.ceil(p * 110 / 100) を比べると、460 件で結果が食い違いました(最初は 50, 90, 100, 110 円)。軽減税率 8% の p * 1.08 でも 89 件でした(最初は 225 円)。切り捨て(Math.floor)では同じ範囲で食い違いは 0 件でしたが、それは誤差がたまたま上向きだっただけです。税率は整数(110 や 108)でかけてから 100 で割る、これが一番安全です。
NaN には独自のルールがあります。
const age = Number("twenty");
print(age, typeof age); // NaN number
print(age === NaN, age == NaN); // false false
print(Number.isNaN(age), isNaN("hello"), Number.isNaN("hello")); // true true false
print(parseInt("42px"), Number("42px"), Number(""), Number(" 7 "), Number(null));
// 42 NaN 0 7 0
NaN は自分自身を含めて何とも等しくありません。古いグローバル関数 isNaN("hello") は先に変換するので true を返します。変換しない Number.isNaN を使ってください。parseInt("42px") は数字以外が出てくるまで読み、Number("42px") は全体を拒否します。そして Number("") は NaN ではなく 0 です(練習問題2)。
文字列とテンプレートリテラル
const price = 1200, qty = 3;
print(`合計: ${price * qty} 円(${qty} 点)`); // 合計: 3600 円(3 点)
print('合計: ${price}'); // 合計: ${price}
print(`${null} ${undefined} ${[1, 2]} ${{a: 1}}`); // null undefined 1,2 [object Object]
バッククォートで囲むのがテンプレートリテラルで、${...} に任意の式を埋め込め、改行もそのまま書けます。シングル・ダブルクォートでは埋め込まれず、2行目は ${price} がそのまま出ました。オブジェクトを ${} に入れると [object Object] になるので、中身を見たいときは JSON.stringify(obj) を使います。
よくある間違い:フォームの値をそのまま足す
間違い。 テキスト入力、URL パラメータ、prompt()、多くの CSV の値は、数字に見えても文字列です。
const input = "10";
print(input + 5, input - 5, input * 2); // 105 5 20
実際に起きること。 + は「足し算」と「文字列の連結」の両方を意味し、どちらかが文字列なら連結になります。それ以外の算術演算子は数学の意味しかないので、文字列を数値に変換します。"3" * "4" は 12、有名な "b" + "a" + +"a" + "a" は baNaNa でした(単項の +"a" が NaN)。例外は出ず、間違った数字がそのまま合計に流れます。
日本語の入力欄ではもう1つ罠があります。全角数字です。
print(Number("10"), Number("10".normalize("NFKC")), "10".normalize("NFKC") + 5);
// NaN 10 105
print(Number("1,200"), parseInt("1,200"), Number("1,200".replaceAll(",", "")));
// NaN 1 1200
Number("10") は NaN、normalize("NFKC") で半角にすれば 10 です。ただし半角にしても文字列のままなので、+ 5 はやはり "105" です。桁区切りの "1,200" は parseInt だと黙って 1 になります。
直し方。 入力を受け取った境界で一度だけ変換し、確認します。
const qty = Number(input.normalize("NFKC").replaceAll(",", ""));
if (!Number.isFinite(qty)) throw new Error(`数値ではありません: ${input}`);
print(qty + 5); // 15
既定値に || を使うのも同じ系統のバグです。|| はfalsy な値(0、""、NaN、null、undefined、false)をすべて置き換えるので、数量 0 が消えます。
const qty0 = 0;
print(qty0 || 10, qty0 ?? 10); // 10 0
??(Null 合体演算子)は null と undefined だけを置き換えます。既定値にはこちらを使います。
実測:金額を小数で足すか、整数で足すか
浮動小数点の誤差は実際どのくらいたまるのか。0.10(10 セント)を小数として N 回、10 を整数のセントとして N 回足しました(drift.js、乱数なし)。
N=10: float=0.9999999999999999 cents/100=1 error=-1.11e-16
N=1000: float=99.9999999999986 cents/100=100 error=-1.41e-12
N=1000000: float=100000.00000133288 cents/100=100000 error=1.33e-6
N=10000000: float=999999.9998389754 cents/100=1000000 error=-1.61e-4
1,000 万回で 0.00016 のずれです。1枚のレシートでは見えませんが、銀行の入出金と1円単位で合わせる月次の集計では表に出ます。整数版は毎回ぴったりです。
速度も測りました。N = 10,000,000 の各ループを performance.now() で計測し、1プロセス内で5回、別プロセスで2回、Apple M1 Pro、macOS 26.6 の jsc です。
float ms: 10.8, 9.5, 9.6, 9.5, 9.5
cents ms: 4.7, 3.2, 3.1, 3.2, 3.2
float ms: 10.7, 9.5, 9.5, 9.4, 9.5
cents ms: 4.4, 3.2, 3.1, 3.1, 3.2
2回目以降で約 9.5 ms 対 3.2 ms です(1回目は JIT コンパイルを含むので遅く、これは第6回で扱います)。理由をエンジン内部まで追って確かめたわけではありません。小さな整数を高速な整数表現のまま扱えることと、浮動小数点の足し算が前の結果を待つ必要があることが考えられますが、推測です。整数で持つ理由はあくまで正確さで、速さはおまけです。なお jsc 自体の起動は軽く、jsc hello2.js は 20 回の中央値で 8.0 ms でした(3バッチで 8.0・8.0・8.1 ms)。
正直な限界とバージョンメモ
- エンジン: macOS 26.6.2 同梱の JavaScriptCore(フレームワークのバージョン 21624.5.1.11.3)を
jscシェルで実行しました。Node.js・Deno・ブラウザは実行していません。上の等価・typeof・数値の結果は ECMAScript 仕様で決まっているので他のエンジンでも同じになるはずですが、エラーメッセージの文言はエンジンごとに違います。 - jsc だけの事情:
printはエンジンのシェルにしかありません。このjscではconsoleがundefinedでした(ブラウザと Node にはあります)。DOM もprompt()もfetchもありません。 - バージョン:
let・const・テンプレートリテラル・Number.isNaN・Number.EPSILON・Symbolは ES2015、BigInt・??・globalThisは ES2020、数値の区切り文字(10_000_000)は ES2021 で入りました。すべてこのjscで動きました。replaceAllは ES2021 です。 - 時間の計測は1台・1バージョンでの値で、桁の感覚をつかむためのものです。エンジンの優劣を比べるものではありません。
練習問題
1. 出力を予想してから実行してください:print("5" + 3, "5" - 3, [] == false, null == 0, typeof NaN);
2. フォームの文字列を受け取り、"29" なら true、"abc"・""・" "・"29.5" なら false を返す isValidAge(s) を書いてください。
3. 次のコードは 0.6000000000000001 を表示します。0.60 と表示させ、なぜ正確になるのかを説明してください。
let total = 0;
for (const p of [0.1, 0.2, 0.3]) total += p;
print(total);
解答
1. 実際の出力は 53 2 true false number です。+ は文字列があれば連結、- は数値に変換、[] は "" を経て 0 に、false も 0 になり、null がゆるく等しいのは undefined だけです。
2. すぐ思いつく Number.isInteger(Number(s)) && Number(s) >= 0 は2か所で失敗します。Number("") と Number(" ") は 0、Number("0x1F") は 31 です。実行してみると "0x1F" が通ってしまいました。厳密な版はこうです。
function isValidAge(s) {
const t = s.trim();
return /^\d{1,3}$/.test(t) && Number(t) < 150;
}
"29" と " 7 " で true、"abc"・""・" "・"29.5"・"-1"・"0x1F"・"1e2" で false でした。全角の「29」も受け付けたいなら、先に s.normalize("NFKC") をかけます。(正規表現は正規表現とはで扱っています。)
3. セント(または円)を整数で持ち、最後に表示用に整形します。let cents = 0; for (const p of [10, 20, 30]) cents += p; print((cents / 100).toFixed(2)); は 0.60 を表示しました。整数の足し算は正確で、割り算は表示の直前に1回だけです。
次回
第2回は関数です。関数宣言とアロー関数の違い、this の正体、クロージャ、ループの中の var の罠、関数の中で出る TDZ エラーを実際に起こしてみます。
よくある質問
JavaScript で == と === はどちらを使うべき?
基本は === です。== は比較の前に型を変換するため、0 == ""、"0" == false、[] == false がすべて true になる一方、"" == "0" は false です。意図して == を使ってよいのは、null と undefined の両方だけに true になる x == null くらいです。
0.1 + 0.2 が 0.3 にならないのはなぜ?
JavaScript の数値はすべて 64 ビットの2進浮動小数点数で、0.1 や 0.2 は2進数で正確に表せないからです。結果は 0.30000000000000004 になります。比較には許容誤差を使い、表示のときだけ丸め、金額は整数(円やセント)で持つのが安全です。100 * 1.1 は 110.00000000000001 になり、Math.ceil で切り上げると 111 円になりました。
typeof null が "object" になるのはなぜ?
JavaScript 最初期の実装のバグで、古い Web ページを壊さないために今も直せません。null は独立したプリミティブ型です。null の判定は value === null、配列の判定は Array.isArray を使います(typeof [] も "object" です)。