JavaScriptの小数計算誤差とは、Number型が数値を2進数の浮動小数点(IEEE 754 倍精度)で持つため、0.1 のような10進数の小数をぴったり表せずに生じるわずかなズレのことです。 0.1 + 0.2 が 0.30000000000000004 になるのも、(1.005).toFixed(2) が "1.00" になるのも、同じ原因から起きています。

この記事では、誤差が出る代表例を Node.js 22.22(V8 12.4)で実際に実行した結果 で一覧にし、なぜ起きるかを図で短く整理したうえで、「整数で持つ」「EPSILONで比べる」「表示だけ丸める」「decimal.jsを使う」のどれを選べばよいかの判断表 と、負の数でも正しく動く四捨五入・切り捨て・切り上げ・偶数丸めのコピペ関数 を載せます。最後に、金額計算で迷わないための推奨パターンもまとめます。


小数計算誤差と対処法の早見表

まずはこの表で「何が起きるか」と「どう直すか」をつかめます。 実行結果はすべて Node.js 22.22.2 で確認したものです。

やりたいこと・起きること そのまま書いた結果 おすすめの書き方 結果
0.1 + 0.2 を足す 0.30000000000000004 最小単位の整数で計算 (10 + 20) / 100 0.3
0.1 + 0.2 === 0.3 を比べる false 相対誤差で比較 nearlyEqual() true
1.005 を小数2桁に四捨五入 (1.005).toFixed(2) → "1.00" roundHalfUp(1.005, 2)(本文の関数) 1.01
1.005 を小数2桁に四捨五入 Math.round(1.005 * 100) / 100 → 1 同上、または new Decimal("1.005").toFixed(2) 1.01
1.15 × 100 114.99999999999999 new Big("1.15").times(100) 115
90円の3割引を切り捨て Math.floor(90 * 0.7) → 62 Math.floor(90 * 7 / 10) 63
-2.5 を四捨五入 Math.round(-2.5) → -2 roundHalfUp(-2.5) -3
偶数丸め(最近接偶数への丸め) 組み込み関数なし roundHalfEven() / Intl.NumberFormat の halfEven 2.5 → 2
表示だけ桁をそろえる toFixed は誤差の影響を受ける Intl.NumberFormat の maximumFractionDigits + roundingMode "1.01"
2^53 を超える整数 2 ** 53 + 1 → 9007199254740992 BigInt:2n ** 53n + 1n 9007199254740993n
JSONの大きなID JSON.parse で末尾が変わる IDは文字列で受け取る 元のまま

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScriptの小数計算誤差と丸め|toFixedの落とし穴と正しい対処法」


誤差が出る例の一覧(Node.js 22 で実行)

誤差は「足し算」だけでなく、掛け算・丸め・大きな整数でも起きます。 次のコードをそのまま実行すると、代表的なパターンを一度に確認できます。

// err.mjs  実行: node err.mjs (Node.js 22.22.2)
const cases = {
  "0.1 + 0.2": 0.1 + 0.2,
  "0.1 + 0.2 === 0.3": 0.1 + 0.2 === 0.3,
  "0.3 - 0.1": 0.3 - 0.1,
  "1.15 * 100": 1.15 * 100,
  "0.07 * 100": 0.07 * 100,
  "1.1 * 3": 1.1 * 3,
  "(1.005).toFixed(2)": (1.005).toFixed(2),
  "(1.255).toFixed(2)": (1.255).toFixed(2),
  "(1.45).toFixed(1)": (1.45).toFixed(1),
  "(2.5).toFixed(0)": (2.5).toFixed(0),
  "Math.round(1.005 * 100) / 100": Math.round(1.005 * 100) / 100,
  "2 ** 53": 2 ** 53,
  "2 ** 53 + 1": 2 ** 53 + 1,
  "Number.MAX_SAFE_INTEGER": Number.MAX_SAFE_INTEGER,
  "Number.MAX_SAFE_INTEGER + 2": Number.MAX_SAFE_INTEGER + 2,
  "Number.isSafeInteger(2 ** 53)": Number.isSafeInteger(2 ** 53),
  "9007199254740993": 9007199254740993,
  "Number.EPSILON": Number.EPSILON,
};
for (const [k, v] of Object.entries(cases)) console.log(k.padEnd(34), "→", v);

実行結果:

0.1 + 0.2                          → 0.30000000000000004
0.1 + 0.2 === 0.3                  → false
0.3 - 0.1                          → 0.19999999999999998
1.15 * 100                         → 114.99999999999999
0.07 * 100                         → 7.000000000000001
1.1 * 3                            → 3.3000000000000003
(1.005).toFixed(2)                 → 1.00
(1.255).toFixed(2)                 → 1.25
(1.45).toFixed(1)                  → 1.4
(2.5).toFixed(0)                   → 3
Math.round(1.005 * 100) / 100      → 1
2 ** 53                            → 9007199254740992
2 ** 53 + 1                        → 9007199254740992
Number.MAX_SAFE_INTEGER            → 9007199254740991
Number.MAX_SAFE_INTEGER + 2        → 9007199254740992
Number.isSafeInteger(2 ** 53)      → false
9007199254740993                   → 9007199254740992
Number.EPSILON                     → 2.220446049250313e-16

ポイントは3つです。

種類 例 何が起きているか
演算の誤差 0.1 + 0.2、1.15 * 100 入力の時点でわずかにズレた値どうしを計算するので、ズレが表に出る
丸めの誤差 (1.005).toFixed(2) → "1.00" 1.005 は内部で 1.00499999… なので「5未満」と判定される
整数の精度 2 ** 53 + 1 2^53(約9千兆)を超えると、隣り合う整数を区別できない

割引・税計算でも起きる例

掛け算のあとに Math.floor で切り捨てると、1円ずれることがあります。 1〜5000円を総当たりで確かめると、Math.floor(price * 0.7) と Math.floor(price * 7 / 10) の結果が違う価格が 70件 ありました(最初の例は 90円)。

// 1〜5000円で「3割引の切り捨て」が食い違う価格を数える
let bad = [];
for (let p = 1; p <= 5000; p++) {
  if (Math.floor(p * 0.7) !== Math.floor(p * 7 / 10)) bad.push(p);
}
console.log(bad.length, bad.slice(0, 5), 90 * 0.7);
// 実行結果: 70 [ 90, 170, 180, 330, 340 ] 62.99999999999999

p * 7 / 10 のように 「整数どうしを掛けてから、最後に1回だけ割る」 と、割り算の結果は正しく丸められた値になるため、この範囲では食い違いが出ませんでした。なお、同じ方法で 0.9・0.8・1.05 倍を調べたところ 1〜5000 の範囲では食い違いは出ず、1.15 倍では 97 件ありました。「たまたま出ない率」もあるので、テストで見つけにくいのが特徴です。


なぜ誤差が起きるのか(2進数の浮動小数点を図で)

10進数の 0.1 は、2進数では「0.0001100110011…」と無限に続く小数になります。 Number 型は仮数部 52 ビット(実質 53 ビット)しか持てないので、途中で切って一番近い値に丸めて保存します。

flowchart LR
  A["ソースコードの 0.1"] --> B["2進数に変換<br/>0.000110011001100…(無限に続く)"]
  B --> C["53ビットで丸めて保存<br/>実際の値 0.1000000000000000055511…"]
  C --> D["計算(0.1 + 0.2)"]
  D --> E["結果も53ビットに丸める<br/>0.3000000000000000444…"]
  E --> F["表示:同じ値になる最短の10進数<br/>0.30000000000000004"]

toPrecision(21) で、保存されている値を21桁まで表示すると、ズレが見えます。

console.log((0.1).toPrecision(21), (1.005).toPrecision(21), (1.15).toPrecision(21), (0.07).toPrecision(21), (1.45).toPrecision(21));
// 実行結果: 0.100000000000000005551 1.00499999999999989342 1.14999999999999991118 0.0700000000000000066613 1.44999999999999995559
ソースの値 実際に保存されている値(21桁) 起きること
1.005 1.00499999999999989342 小数2桁の四捨五入で「切り捨て側」になる
1.15 1.14999999999999991118 ×100 が 114.999… になる
0.07 0.0700000000000000066613 ×100 が 7.000…001 になる
1.45 1.44999999999999995559 toFixed(1) が "1.4" になる

console.log(0.1) が 0.1 と表示されるのは、「その値と区別できる最短の10進数」を表示しているからです。 保存値は最初からズレていて、計算を重ねるとズレが表示の桁まで上がってきます。

2^53 を超える整数

整数も、Number.MAX_SAFE_INTEGER(9007199254740991)までしか正確に表せません。 それより大きいと、隣の整数との間隔が 2 以上になります。データベースの 64bit ID や、ナノ秒のタイムスタンプを Number で受けると末尾が変わるのはこのためです。

console.log(JSON.parse('{"id": 9007199254740993}').id);
// 実行結果: 9007199254740992

大きなIDは、APIの段階で文字列として受け渡すのが確実です。


対処法の判断表:どれを選べばよいか

目的によって正解が変わります。 「計算を正確にしたい」のか「表示を整えたい」のか「比較したい」のかで選びます。

方法 向いている用途 長所 注意点
最小単位の整数で持つ(円・セント・グラム) 金額、数量、ポイント 依存なし・速い・誤差なし 割り算・率の計算で丸め位置を決める必要がある
Number.EPSILON を使った比較 計算結果が「ほぼ等しいか」の判定 1行で書ける 大きな値では相対誤差にする必要がある
Math.round(x * 10 ** n) / 10 ** n 手早く表示用に丸める 短い 1.005 → 1 のように境界で外れる
指数表記シフトの丸め関数(本文) 依存なしで小数n桁の丸め 1.005 → 1.01 になる 入力自体が誤差を含む計算結果だと限界あり
toFixed(n) 文字列として桁をそろえる 末尾0を補う("1.500") 保存値で判定するので 1.005 → "1.00"
toPrecision(n) 有効数字でそろえる 桁数の大小に強い 大きな値は指数表記になる
Intl.NumberFormat(roundingMode) 画面表示・桁区切り・通貨記号 9種類の丸め方を指定できる 戻り値は文字列。計算には使わない
BigInt 2^53 超の整数、整数どうしの計算 桁数の制限なし 小数は扱えない。Number と混ぜられない
decimal.js / big.js 小数の計算そのものを正確にしたい 10進数で計算・丸めモード豊富 依存が増える。文字列で渡す必要がある

迷ったら「データは整数(最小単位)で持つ」「表示は Intl.NumberFormat」「率や割り算が多い計算は decimal.js」の3つを基本にすると整理しやすくなります。


比較するなら Number.EPSILON(相対誤差で)

Number.EPSILON(約 2.22e-16)は「1 と、1 の次に大きい Number との差」です。 0付近の値ならそのまま閾値に使えますが、値が大きくなると誤差も大きくなるため、値の大きさに合わせて閾値を広げます。

// eps.mjs
const nearlyEqual = (a, b, eps = Number.EPSILON) =>
  Math.abs(a - b) < eps * Math.max(1, Math.abs(a), Math.abs(b));

console.log("nearlyEqual(0.1+0.2, 0.3):", nearlyEqual(0.1 + 0.2, 0.3));
console.log("Math.abs(0.1+0.2-0.3) < Number.EPSILON:", Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
console.log("1000.1+1000.2 vs 2000.3 単純EPSILON:",
  Math.abs(1000.1 + 1000.2 - 2000.3) < Number.EPSILON, " 相対:", nearlyEqual(1000.1 + 1000.2, 2000.3));

実行結果:

nearlyEqual(0.1+0.2, 0.3): true
Math.abs(0.1+0.2-0.3) < Number.EPSILON: true
1000.1+1000.2 vs 2000.3 単純EPSILON: false  相対: true

単純に < Number.EPSILON と書くと、1000 程度の値で false になります。 計算を何回も重ねる場合は、eps を Number.EPSILON * 10 などに広げて使います。


四捨五入・切り捨て・切り上げ・偶数丸めのコピペ関数

x * 10 ** n の掛け算で誤差が入るのを避けるため、「指数表記の文字列で桁をずらす」方法を使います。 1.005 を "1.005e2" として Number() に渡すと、10進数の 100.5 として読み直されるので、境界の判定が見た目どおりになります。

// round.mjs  依存なし(Node.js 22 / ブラウザ共通)
// 指数表記で桁をずらす(x * 10**n の掛け算誤差を避ける)
function shift(x, n) {
  const [m, e = "0"] = String(x).split("e");
  return Number(`${m}e${Number(e) + n}`);
}
// 四捨五入(0から遠い方へ:負の数も -1.5 → -2)
export function roundHalfUp(x, digits = 0) {
  const s = Math.sign(x);
  return s * shift(Math.round(shift(Math.abs(x), digits)), -digits);
}
// 切り捨て(0方向)
export function truncate(x, digits = 0) {
  return shift(Math.trunc(shift(x, digits)), -digits);
}
// 切り下げ(-∞方向)
export function floor(x, digits = 0) {
  return shift(Math.floor(shift(x, digits)), -digits);
}
// 切り上げ(+∞方向)
export function ceil(x, digits = 0) {
  return shift(Math.ceil(shift(x, digits)), -digits);
}
// 偶数丸め(ちょうど半分なら偶数側へ)
export function roundHalfEven(x, digits = 0) {
  const v = shift(x, digits);
  const r = Math.round(v);
  const isHalf = Math.abs(v % 1) === 0.5;
  const res = isHalf ? (r % 2 === 0 ? r : r - 1) : r;
  return shift(res, -digits);
}

確認用のコードと実行結果です(小数2桁)。

// test_round.mjs
import { roundHalfUp, truncate, floor, ceil, roundHalfEven } from "./round.mjs";
const xs = [1.005, 1.255, 1.15, 2.345, -1.005, -2.345, 1e-7, 123.456e5];
console.log("x".padEnd(12), "Math.round(x*100)/100", "roundHalfUp", "truncate", "floor", "ceil", "halfEven");
for (const x of xs) console.log(String(x).padEnd(12), Math.round(x*100)/100,
  roundHalfUp(x,2), truncate(x,2), floor(x,2), ceil(x,2), roundHalfEven(x,2));
x            Math.round(x*100)/100 roundHalfUp truncate floor ceil halfEven
1.005        1 1.01 1 1 1.01 1
1.255        1.25 1.26 1.25 1.25 1.26 1.26
1.15         1.15 1.15 1.15 1.15 1.15 1.15
2.345        2.35 2.35 2.34 2.34 2.35 2.34
-1.005       -1 -1.01 -1 -1.01 -1 -1
-2.345       -2.35 -2.35 -2.34 -2.35 -2.34 -2.34
1e-7         0 0 0 0 0.01 0
12345600     12345600 12345600 12345600 12345600 12345600 12345600

Math.round(x*100)/100 は 1.005 と 1.255 で外れますが、roundHalfUp は見た目どおり 1.01・1.26 になります。 1e-7 のような指数表記の入力も、shift が指数部を足し引きするので正しく扱えます。

負の数での挙動(Math.round(-2.5) は -2)

Math.round は「ちょうど半分なら +∞ 方向」に丸めるため、Math.round(-2.5) は -2 になります。 日本で一般的な「四捨五入」(0から遠い方へ)とは負の数で結果が変わります。

x Math.round roundHalfUp roundHalfEven Math.trunc Math.floor Math.ceil
2.5 3 3 2 2 2 3
3.5 4 4 4 3 3 4
-2.5 -2 -3 -2 -2 -3 -2
-3.5 -3 -4 -4 -3 -4 -3
0.5 1 1 0 0 0 1
-0.5 -0 -1 0 -0 -1 -0
1.5 2 2 2 1 1 2

(Node.js 22.22.2 の実行結果。-0 は console.log の表示どおり)

呼び方 方向 JavaScriptの関数
切り捨て(0方向) 正は小さく、負は大きく Math.trunc / truncate()
切り下げ(床関数) 常に小さい方へ Math.floor / floor()
切り上げ(天井関数) 常に大きい方へ Math.ceil / ceil()
四捨五入(0から遠い方) 半分は絶対値が大きい方 roundHalfUp()
半分は+∞方向 半分は大きい方 Math.round
偶数丸め 半分は偶数側 roundHalfEven()

負の数を「切り捨て」と言うときは、Math.trunc(0方向)か Math.floor(-∞方向)かを仕様で決めておく と、あとで迷いません。

丸め関数の限界

この関数は「入力した数値が、見た目どおりの10進数」であることを前提にしています。 たとえば 1.15 * 100 のように、すでに誤差を含んだ計算結果(114.99999999999999)を渡すと、その値として丸めます。計算の途中で誤差が入るなら、計算自体を整数か decimal.js で行うのが確実です。


toFixed・toPrecision・Intl.NumberFormat の違い

toFixed は「保存値」で判定し、文字列を返す

toFixed は内部の正確な値(1.00499999…)を基準に丸めるので、(1.005).toFixed(2) は "1.00" になります。 仕様どおりの動作で、半分ちょうどのときは0から遠い方へ丸めます。

console.log((-2.5).toFixed(0), (-1.005).toFixed(2), typeof (1.5).toFixed(0), (1e21).toFixed(2),
  (1.5).toFixed(3), (0.5).toFixed(0), (8.345).toFixed(2), (8.335).toFixed(2));
try { (1).toFixed(101); } catch (e) { console.log(e.name + ": " + e.message); }
-3 -1.00 string 1e+21 1.500 1 8.35 8.34
RangeError: toFixed() digits argument must be between 0 and 100
toFixed の性質 実例
戻り値は文字列 typeof (1.5).toFixed(0) → "string"
末尾の0を補う (1.5).toFixed(3) → "1.500"
半分ちょうどは0から遠い方へ (-2.5).toFixed(0) → "-3"
保存値で判定するので境界は入力しだい 8.345 → "8.35"、8.335 → "8.34"
1e21 以上は指数表記 (1e21).toFixed(2) → "1e+21"
桁数は 0〜100 101 で RangeError

toFixed の結果を Number() に戻して再計算すると、また誤差が入ります。 表示の直前に1回だけ使います。

toPrecision は有効数字でそろえる

console.log((123.456).toPrecision(4), (0.000123456).toPrecision(2), (123456).toPrecision(2));
// 実行結果: 123.5 0.00012 1.2e+5

測定値やグラフのラベルのように「桁数より有効数字」が大事な表示に向いています。 整数部が桁数より長いと指数表記になる点に注意します。

Intl.NumberFormat の roundingMode(Node 22 で利用可)

Intl.NumberFormat は roundingMode で9種類の丸め方を選べます(ECMA-402 の Intl.NumberFormat v3 で追加。Node.js 22 で動作を確認)。桁区切りや通貨記号も同時に付けられるので、画面表示はこれにまとめるのがすっきりします。

// intl_round.mjs
const modes = ["halfExpand", "halfEven", "halfTrunc", "trunc", "floor", "ceil", "expand", "halfCeil", "halfFloor"];
for (const m of modes) {
  const nf = new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2, roundingMode: m });
  console.log(m.padEnd(11), [1.005, 1.255, 2.345, -2.345, 0.125].map(v => nf.format(v)).join("  "));
}
const nf0 = new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 0, roundingMode: "halfEven" });
console.log("halfEven 0桁:", [0.5, 1.5, 2.5, -2.5].map(v => nf0.format(v)).join(" "));
console.log("文字列を渡す:", new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2 }).format("1.005"));
console.log("useGrouping false:", new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2, useGrouping: false }).format("12345.675"));

実行結果(入力:1.005 / 1.255 / 2.345 / -2.345 / 0.125):

halfExpand  1.01  1.26  2.35  -2.35  0.13
halfEven    1  1.26  2.34  -2.34  0.12
halfTrunc   1  1.25  2.34  -2.34  0.12
trunc       1  1.25  2.34  -2.34  0.12
floor       1  1.25  2.34  -2.35  0.12
ceil        1.01  1.26  2.35  -2.34  0.13
expand      1.01  1.26  2.35  -2.35  0.13
halfCeil    1.01  1.26  2.35  -2.34  0.13
halfFloor   1  1.25  2.34  -2.35  0.12
halfEven 0桁: 0 2 2 -2
文字列を渡す: 1.01
useGrouping false: 12345.68
roundingMode 意味 日本語での呼び方の目安
halfExpand(既定) 半分は0から遠い方へ 四捨五入
halfEven 半分は偶数側へ 偶数丸め(最近接偶数への丸め)
halfTrunc 半分は0方向へ 五捨六入に近い
trunc 0方向へ 切り捨て
floor -∞方向へ 切り下げ
ceil +∞方向へ 切り上げ(正の数)
expand 0から遠い方へ 絶対値の切り上げ
halfCeil 半分は+∞方向へ Math.round と同じ方向
halfFloor 半分は-∞方向へ ―

Node.js 22 では format(1.005) が "1.01" になり、toFixed と結果が異なりました。 さらに確実にしたい場合は、format("1.005") のように 文字列で渡す と10進数としてそのまま丸められます。末尾の0をそろえたいときは minimumFractionDigits: 2 を併用します。通貨や桁区切りの細かい書式は、既存記事「JavaScript Intlの使い方」でまとめています。


BigInt:2^53 を超える整数を正確に扱う

BigInt は桁数の制限がない整数型で、数値の末尾に n を付けて書きます。 整数専用なので、小数の誤差の対処には使えませんが、大きなIDや「最小単位の整数」で桁が足りないときに役立ちます。

console.log("2n**53n+1n:", 2n ** 53n + 1n, " 7n/2n:", 7n / 2n, " -7n/2n:", -7n / 2n);
try { console.log(1n + 1); } catch (e) { console.log(e.constructor.name + ": " + e.message); }
try { console.log(JSON.stringify({ id: 1n })); } catch (e) { console.log(e.constructor.name + ": " + e.message); }
2n**53n+1n: 9007199254740993n  7n/2n: 3n  -7n/2n: -3n
TypeError: Cannot mix BigInt and other types, use explicit conversions
TypeError: Do not know how to serialize a BigInt
BigInt の性質 対応
割り算は0方向に切り捨て(-7n / 2n → -3n) 四捨五入が必要なら余りを見て自分で調整
Number と混ぜると TypeError BigInt(x) / Number(x) で明示的に変換
JSON.stringify できない toString() で文字列にしてから送る
Math.* が使えない 演算子(+ - * / % **)で書く

decimal.js / big.js:小数を10進数のまま計算する

計算そのものを10進数で行いたいときは、任意精度の10進数ライブラリを使います。 ここでは decimal.js 10.6.0 と big.js 7.0.1 を npm で入れて確認しました。

npm i decimal.js big.js
// lib.mjs
import Decimal from "decimal.js";
import Big from "big.js";
console.log("Decimal 0.1+0.2:", new Decimal("0.1").plus("0.2").toString());
console.log("Decimal 1.005 toFixed(2):", new Decimal("1.005").toFixed(2),
  " toDP HALF_EVEN:", new Decimal("1.005").toDecimalPlaces(2, Decimal.ROUND_HALF_EVEN).toString());
console.log("Decimal(1.005)数値から:", new Decimal(1.005).toString());
console.log("Decimal -2.5 round(HALF_UP):", new Decimal("-2.5").toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString());
console.log("Decimal 1/3:", new Decimal(1).div(3).toString());
console.log("Big 0.1+0.2:", new Big("0.1").plus("0.2").toString(),
  " Big 1.005 round(2):", new Big("1.005").round(2).toString(),
  " round half-even:", new Big("1.005").round(2, Big.roundHalfEven).toString());
console.log("Big 1.15*100:", new Big("1.15").times(100).toString());
Decimal 0.1+0.2: 0.3
Decimal 1.005 toFixed(2): 1.01  toDP HALF_EVEN: 1
Decimal(1.005)数値から: 1.005
Decimal -2.5 round(HALF_UP): -3
Decimal 1/3: 0.33333333333333333333
Big 0.1+0.2: 0.3  Big 1.005 round(2): 1.01  round half-even: 1
Big 1.15*100: 115
ライブラリ 特徴 向いている用途
big.js 小さく、API が少ない 四則演算と丸めだけで足りる計算
decimal.js 精度・丸めモードを細かく設定、三角関数なども対応 率の計算、割り算、統計

値は文字列で渡すのが基本です。 new Decimal(1.005) は最短表記の "1.005" として扱われましたが、計算結果の Number(誤差を含む値)を渡すとその誤差ごと取り込みます。入力値はフォームや API の文字列のまま渡すと安心です。1/3 のような割り切れない計算は、decimal.js の既定精度(有効数字20桁)で丸められます。


金額計算の推奨策

金額は「最小単位の整数で持ち、率は整数比で計算し、丸めは最後に1回、表示は Intl.NumberFormat」が基本です。

// money.mjs
// 金額は最小単位(円・セント)の整数で持つ
const items = [
  { name: "ノート", unitPrice: 1999, qty: 3 },  // 19.99 ドルをセントで
  { name: "ペン",   unitPrice: 110,  qty: 7 },  //  1.10 ドル
];
const subtotal = items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
// 税率は「百分率の整数」で持ち、整数演算 → 最後に1回だけ丸める
const taxRatePercent = 10;
const tax = Math.floor((subtotal * taxRatePercent) / 100);
const total = subtotal + tax;
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
console.log({ subtotal, tax, total });
console.log("表示:", fmt.format(subtotal / 100), fmt.format(tax / 100), fmt.format(total / 100));
console.log("Number.isSafeInteger(total):", Number.isSafeInteger(total));
{ subtotal: 6767, tax: 676, total: 7443 }
表示: $67.67 $6.76 $74.43
Number.isSafeInteger(total): true

金額計算のチェックリスト

チェック項目 推奨
金額の持ち方 最小単位(円・セント)の整数
率(税率・割引率) 10(%)や 7/10 のような整数比で持つ
丸めのタイミング 明細ごとか合計か、仕様で決めて1か所で丸める
丸めの方向 切り捨て・四捨五入・切り上げを仕様で明記
負の金額(返金など) Math.trunc と Math.floor のどちらか決める
大きな合計 Number.isSafeInteger() で確認、超えるなら BigInt
割り算・按分が多い decimal.js / big.js を使う
表示 Intl.NumberFormat(文字列)で、計算には戻さない
サーバー・DBとの受け渡し 文字列か整数で送る(DB側は DECIMAL 型など)

同じ金額をフロントエンドとサーバーの両方で計算する場合は、丸めの方向と位置を両方で同じにしておく と、表示と保存値がそろいます。Java 側の扱いは既存記事「Java BigDecimalの使い方」で解説しています。


よくある質問

Q. toFixed を使えば誤差は解決しますか? A. 表示の桁をそろえる用途には使えますが、(1.005).toFixed(2) が "1.00" になるように、保存値の誤差は残ります。四捨五入の結果を見た目どおりにしたいなら、本文の roundHalfUp か、Intl.NumberFormat に文字列を渡す方法、decimal.js を使います。

Q. Math.round(x * 100) / 100 は使ってもよいですか? A. 大半の値では期待どおりですが、1.005 や 1.255 のような境界の値で外れます。境界が問題にならない用途(グラフ表示など)なら手軽な選択肢です。金額や採点など境界が大事な計算では、指数表記シフトの関数かライブラリを使うと確実です。

Q. TypeScript なら誤差は起きませんか? A. TypeScript の number も実行時は JavaScript の Number なので、同じ誤差が起きます。型で区別したい場合は、金額を type Cents = number & { __brand: "Cents" } のようなブランド型にして「整数で持つ」ことを型で表す方法があります。

Q. 偶数丸め(最近接偶数への丸め)はいつ使いますか? A. 半分ちょうどを常に切り上げると、大量に合計したときに上方向へ偏ります。統計や集計で偏りを小さくしたいときに偶数丸めを使います。表示だけなら Intl.NumberFormat の roundingMode: "halfEven"、計算なら decimal.js の ROUND_HALF_EVEN が手軽です。


まとめ

  • 誤差の原因は、10進数の小数を2進数の53ビットで近似していること(0.1 は 0.1000000000000000055511… として保存)
  • (1.005).toFixed(2) が "1.00"、Math.round(-2.5) が -2 になるのは仕様どおりの動作
  • 比較は相対誤差の nearlyEqual() で。単純な < Number.EPSILON は大きな値で外れる
  • 丸めは指数表記シフトのコピペ関数で、負の数・偶数丸めまで見た目どおりにできる
  • 表示は Intl.NumberFormat の roundingMode(Node 22 で9種類を確認)、文字列で渡すとより確実
  • 金額は最小単位の整数+整数比の率+最後に1回丸める。割り算が多ければ decimal.js / big.js
  • 2^53 を超える整数は BigInt、APIのIDは文字列で受け渡す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScriptの小数計算誤差と丸め|toFixedの落とし穴と正しい対処法」

株式会社RJCでは、数値計算の正確な扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?