JavaScriptの金額計算で1円ずれる原因|割引・税抜の floor と toFixed の落とし穴
「3割引きにしたら1円少ない」「税抜価格を逆算したら1円合わない」。JavaScript で金額を計算していて、手計算や Excel と1円だけ食い違う場面は珍しくありません。原因はたいてい小数を2進数で持つ仕組みと、切り捨て・四捨五入を小数の計算結果に直接かけていることです。この記事では、どの計算でずれるのか、どの計算ではずれないのかを Node.js で実測し、直し方までまとめます。
結論:円は整数で持ち、掛け算してから割る
金額は「円」の整数で持ち、割合は分数(70/100 のような整数同士)で表して、掛けてから割るのが基本です。小数の割合(0.7 や 1.15)を掛けて Math.floor するのは避けます。
| やりたいこと | ずれやすい書き方 | 整数で書く方法 |
|---|---|---|
| 3割引(切り捨て) | Math.floor(p * 0.7) | Math.floor(p * 70 / 100) |
| 15%上乗せ(切り捨て) | Math.floor(p * 1.15) | Math.floor(p * 115 / 100) |
| 税抜の逆算(切り捨て) | Math.floor(p / 1.1) | Math.floor(p * 10 / 11) |
| 税込(切り上げ) | Math.ceil(p * 1.1) | Math.ceil(p * 110 / 100) |
小数の割合を掛けた結果は「ほんの少し小さい」または「ほんの少し大きい」数になることがあり、そこに切り捨て・切り上げをかけると境目で1円ずれます。
なお、以下の数値はすべて Node.js v20.20.1 での実測です。ブラウザや他の言語の挙動は検証していません(JavaScript の Number は IEEE 754 の倍精度なので同様と考えられますが、確認はしていません)。
なぜ1円ずれるのか
JavaScript の数値は2進数の浮動小数点です。0.1 や 1.1 のような10進の小数は、2進数では正確に表せません。次の結果は、実際に保持されている値を桁数を増やして表示したものです。
(0.1).toPrecision(20); // "0.10000000000000000555"
(1.1).toPrecision(20); // "1.1000000000000000888"
(1.005).toPrecision(20); // "1.0049999999999998934"
1.005 は実際には 1.005 より少し小さい値として保持されています。このため 0.1 + 0.2 が 0.30000000000000004 になり、小数どうしの掛け算でも「あと一歩で整数」の値が出ます。
90 * 0.7; // 62.99999999999999
Math.floor(90 * 0.7); // 62(手計算では63)
本来 63 になるはずの値が 62.99999999999999 になり、Math.floor が 62 に落とします。これが「1円ずれる」の正体です。
どの計算でずれるのか(実測)
「小数を掛けるとずれる」と一括りにされがちですが、実際にはずれる割合とずれない割合があります。1〜100,000円の整数について、小数で計算した結果を、整数で計算した結果(p * 70 / 100 のような形)と比較しました。
| 計算 | 一致しなかった件数(1〜100,000円) | ずれる例 |
|---|---|---|
Math.floor(p * 1.1)(税込10%・切り捨て) | 0件 | なし |
Math.floor(p * 1.08)(税込8%・切り捨て) | 0件 | なし |
Math.floor(p * 0.1)(消費税額10%) | 0件 | なし |
Math.floor(p * 0.8)(2割引) | 0件 | なし |
Math.floor(p * 0.9)(1割引) | 0件 | なし |
Math.floor(p * 0.95)(5%引) | 0件 | なし |
Math.floor(p * 0.7)(3割引) | 2,336件 | 90円 → 62(正しくは63) |
Math.floor(p * 1.15)(15%上乗せ) | 2,502件 | 100円 → 114(正しくは115) |
Math.ceil(p * 1.1)(税込10%・切り上げ) | 5,524件 | 100円 → 111(正しくは110) |
Math.floor(p / 1.1)(税抜逆算) | 4,174件 | 33円 → 29(正しくは30) |
Math.floor(p / 1.08)(税抜逆算) | 818件 | 135円 → 124(正しくは125) |
Math.floor(p * 0.29) | 310件 | 100円 → 28(正しくは29) |
ここで見落としやすい点があります。税込10%や8%を切り捨てで計算するだけなら、この範囲ではずれませんでした。 「price * 1.1 は危ない」と覚えていると、問題のない所に余計な対策を入れ、本当にずれる所(切り上げ、逆算、3割引)を見逃します。
ただし、これは「1〜100,000円の整数」「この4種類の丸め方」で調べた結果です。小数の単価や数量を掛ける場合、金額がさらに大きい場合は、同じ結果になるとは限りません。
切り上げと逆算が特にずれやすい理由
切り上げは、本来ちょうど整数になる値(100 * 1.1 は手計算で 110)が、浮動小数点では 110.00000000000001 になると、Math.ceil が 111 に繰り上げます。切り捨てでは「少し大きい」側のずれは無害で、「少し小さい」側だけが問題になります。切り上げではその逆です。
税抜価格の逆算(p / 1.1)は、割り切れる値(33円 ÷ 1.1 = 30)が 29.999999999999996 になり、切り捨てで 29 になります。税込価格から税抜価格を戻す処理は、請求書や帳票の突き合わせで使われやすいため、ここで1円ずれると原因の調査に時間を取られます。
toFixed は「四捨五入」ではない
金額の丸めに toFixed を使う書き方もよく見かけますが、これも小数の2進表現の影響を受けます。次は toFixed(2) の実測です。
| 式 | 結果 | 手計算での期待 |
|---|---|---|
(1.005).toFixed(2) | "1.00" | 1.01 |
(1.015).toFixed(2) | "1.01" | 1.02 |
(1.025).toFixed(2) | "1.02" | 1.03 |
(1.045).toFixed(2) | "1.04" | 1.05 |
(10.235).toFixed(2) | "10.23" | 10.24 |
(8.345).toFixed(2) | "8.35" | 8.35(こちらは一致) |
1.005 は内部では 1.00499999… なので、toFixed は正しく切り下げています。つまり toFixed が間違っているわけではなく、手計算の「ちょうど5」が JavaScript の内部には存在しないのです。
円単位の丸めも同じです。90 * 0.35 は手計算で 31.5 ですが、内部では 31.499999999999996 なので、Math.round(90 * 0.35) は 32 ではなく 31 になります。p * 0.35 で四捨五入した場合、1〜100,000円で1,170件が整数計算の結果と一致しませんでした。
「1.005 を 100 倍して Math.round」も安全ではない
よく紹介される次の書き方も、値によっては効果がありません。
Math.round(1.005 * 100) / 100; // 1 ← 1.01 にならない
Number(Math.round(1.005 + "e2") + "e-2"); // 1.01 ← 文字列で桁をずらす方法
文字列で桁をずらす方法は 1.005 では 1.01 になりますが、非常に小さい数では壊れます。たとえば 1e-7 は文字列にすると "1e-7" になり、"1e-7e2" は数値として読めず、Math.round の結果が NaN になることを実測で確認しました。
実務上の落とし穴:丸めの「向き」と「場所」を決めていないと、後で合わない
ここまでは「ずれる」話でしたが、実務ではもう1つ、ほとんど書かれていない問題があります。どのタイミングで丸めるかが決まっていないと、同じ計算式でも合計が合わなくなることです。
| 丸める場所 | 内容 | 起きやすい食い違い |
|---|---|---|
| 明細ごとに丸める | 1行ごとに税額を切り捨てて合計 | 税額の合計が、合計金額から計算した税額より少なくなることがある |
| 合計で1回だけ丸める | 税抜の合計に税率をかけて1回だけ切り捨て | 明細の税額を足した値と合わなくなることがある |
どちらが正しいかは、請求書の運用ルールや取引先との取り決めで決まる事項で、コードだけでは決められません。税務上の扱いは私の説明できる範囲を超えるため、会計の担当者や税理士に確認してください。コード側でできるのは、「丸める場所」と「丸める向き(切り捨て・切り上げ・四捨五入)」を1か所の関数にまとめて、仕様として明文化することです。
直し方:整数で持ち、掛けてから割る
基本の考え方
- 金額は円の整数で持つ(小数の円を持たない)
- 割合は分数で表し、先に掛けて、最後に割る
- 丸めは1回だけ、最終段階で行う
// 切り捨て(3割引)
const discountFloor = (price, ratePercent) =>
Math.floor(price * (100 - ratePercent) / 100);
discountFloor(90, 30); // 63
// 切り上げ(税込10%)
const taxIncludedCeil = (price) => Math.ceil(price * 110 / 100);
taxIncludedCeil(100); // 110
// 税抜の逆算(切り捨て)
const taxExcludedFloor = (price) => Math.floor(price * 10 / 11);
taxExcludedFloor(33); // 30
整数同士の掛け算は、安全な整数の範囲(Number.MAX_SAFE_INTEGER = 9,007,199,254,740,991)に収まっている限り正確です。price * 110 は、価格が数十億円規模でもこの範囲に収まります。
四捨五入は「5を足して割る」
四捨五入を整数だけで書く場合は、割る数の半分を足してから切り捨てます。
// 価格 × (num/den) を円未満で四捨五入(正の数のみ)
const roundedPercent = (price, num, den) =>
Math.floor((price * num * 2 + den) / (den * 2));
roundedPercent(90, 35, 100); // 32(31.5 を四捨五入)
負数(返金、値引きなど)を扱う場合は注意が必要です。Math.round(-2.5) は -2、(-2.5).toFixed(0) は "-3" と、同じ「四捨五入」でも結果が違うことを実測で確認しました。Math.round は「ちょうど半分は大きい方(正の無限大側)へ」、toFixed は「絶対値で丸めて符号をつける」というように、方式が異なります。負数が絡む金額は、丸めの規則を自分で明示した関数にするのが安全です。
割合に小数点がある場合
「消費税率 8.8%」のように割合に小数がある場合は、割合を整数に直します(8.8% = 88/1000)。
const rate = { num: 88, den: 1000 };
Math.floor(price * rate.num / rate.den);
金額が極端に大きい場合や、小数の円(銭、セント)を扱う場合は、BigInt や、decimal.js のような10進数ライブラリを使う方法もあります。ここでは検証していないため、導入する場合は自分の用途で結果を確認してください。
よくある疑問
toFixed(2) で最後に整えれば大丈夫ではないのか
表示の見た目を整える用途なら問題ありません。ただし、toFixed は小数の2進表現のまま丸めるため、計算結果としての金額に使うと上の表のようにずれる場合があります。表示用と計算用は分けて考えます。
Number.EPSILON を足せばよいのか
Math.round((1.005 + Number.EPSILON) * 100) / 100 は 1.01 になることを実測で確認しました。ただし、値の大きさに応じて必要な補正量が変わるため、すべての値で効く保証はありません。金額計算の一般解としては整数化のほうが確実だと考えられます。
小数どうしの比較(0.1 + 0.2 === 0.3)はどうするか
0.1 + 0.2 === 0.3 は false でした。金額を整数にして比較すれば、この問題はそもそも起きません。小数のまま比較する必要がある場合は、差の絶対値が許容誤差より小さいかで判定します(Math.abs(a - b) < Number.EPSILON は 0.1 + 0.2 と 0.3 では true になりますが、大きな値には使えません)。
検証の範囲と注意点
- 数値はすべて Node.js v20.20.1 で実測しました。ブラウザ、Deno、Bun などは未検証です
- 「一致しなかった件数」は 1〜100,000円の整数、上記の丸め方のみの結果です。他の範囲・小数の単価・数量が絡む計算では結果が変わる可能性があります
Intl.NumberFormatやtoLocaleStringによる丸めの挙動は、この記事では詳しく扱っていません- 税務の扱い(明細ごとの端数処理、請求書の記載ルールなど)は一般的な論点の指摘にとどめており、個別の事案について結論を示すものではありません。実際の運用は会計の担当者や税理士にご確認ください
関連して、日付や文字数でも JavaScript の「見た目と内部の値が違う」ことで同様のずれが起きます。日付については JavaScriptの日付が1日前になる原因、文字数については JavaScriptで絵文字の文字数がずれる原因 も参考にしてください。
まとめ
- 金額計算で1円ずれる原因は、小数が2進数で正確に表せないことと、その結果に
floor/ceil/roundをかけていること - 実測では、税込10%・8%の切り捨てなどはずれませんでしたが、3割引の切り捨て、税込の切り上げ、税抜の逆算ではずれました。「小数を掛ければ必ずずれる」わけではありません
toFixedは四捨五入ではなく、内部の値を丸めます。1.005.toFixed(2)が"1.00"になるのはこのためです- 円は整数で持ち、割合は分数にして「掛けてから割る」。丸めは最後に1回だけ行います
- 丸める場所と向きは取り決めで決まる事項です。関数にまとめて仕様として残し、運用は会計の担当者や税理士に確認してください