漢数字⇔数字変換をBigIntで書く 9999兆はNumberで壊れる

ハシトシステムの漢数字変換ツール は、アラビア数字と漢数字を相互に変換する。123450000 を入れれば「一億二千三百四十五万」、「壱萬」を入れれば 10,000 が返る。領収書で使う大字(壱弐参拾萬)と「金壱萬円也」の形も出す。処理はページ内の JavaScript だけで完結していて、入力はサーバーに送られない。

対応範囲は 16 桁、つまり「九千九百九十九兆九千九百九十九億九千九百九十九万九千九百九十九」までである。この記事では、この範囲を正しく扱うために何をしているかを、ツールのコード(public/tools/kansuji/index.html)に沿って説明する。

9999兆は Number では表せない

JavaScript の Number は倍精度浮動小数点数で、整数を正確に表せるのは Number.MAX_SAFE_INTEGER(2^53 - 1 = 9007199254740991)までである。これを漢数字にすると「九千七兆千九百九十二億五千四百七十四万九百九十一」。9007兆を超えると、隣り合う整数を区別できなくなる。

16 桁の最大値 9999999999999999 はこの外にある。Node.js 24.11.1 で確かめると、数値リテラルにした時点で丸められる。

1
2
String(9999999999999999)   // '10000000000000000'
String(9999999999999999n) // '9999999999999999'

漢数字を数値に戻す処理で「千の位 × 1000 を足し、万が来たら 10000 を掛ける」を Number で積むと、9007兆より上では最後の桁がずれる。ツールはここを BigInt(0n, 10n のようなリテラル)で積み、結果を文字列で返している。数字→漢数字の向きは、入力を最初から文字列のまま 4 桁ずつ切るので、数値型を通らない。

数字 → 漢数字: 4桁ごとに区切って単位を付ける

漢数字の位取りは 4 桁ごとの繰り返しである。下から 4 桁を取り、その中で「千・百・十・一」を付け、区切りごとに「万・億・兆」を付ける。

1
2
3
4
5
6
7
8
9
10
11
12
while (s.length) {
const chunk = s.slice(-4); s = s.slice(0, -4);
let part = '';
for (let i = 0; i < chunk.length; i++) {
const d = +chunk[chunk.length - 1 - i];
if (!d) continue; // 0 の桁は何も書かない
const dg = (d === 1 && i > 0 && !daiji) ? '' : digits[d];
part = dg + units[i] + part;
}
if (part) out = part + groups[gi] + out; // 4桁がすべて 0 なら「万」も付けない
gi++;
}

細かい点が2つある。

  • 「一」を省く場所。通常表記では 1010 は「一千一十」ではなく「千十」と書く。十・百・千の前の「一」は省く(d === 1 && i > 0)。一方で 4 桁区切りの先頭は省かないので、11000 は「一万千」になる。
  • 4 桁がすべて 0 の区切り。100000000(1億)の下 8 桁は 0 なので「一億」だけになり、「一億万」にはならない。

大字(daiji = true)のときは「一」を省かず、使う字も変える。ツールの表は次のとおりで、壱・弐・参・拾・佰・仟・萬を使い、四〜九は通常の字のままにしている。

1
2
3
const digits = daiji ? ['','壱','弐','参','四','五','六','七','八','九'] : ['','一','二','三','四','五','六','七','八','九'];
const units = daiji ? ['','拾','佰','仟'] : ['','十','百','千'];
const groups = daiji ? ['','萬','億','兆'] : ['','万','億','兆'];

この結果、1010 は「壱仟壱拾」、20260 は「弐萬弐佰六拾」になる。大字のときだけ !daiji の条件で「一を省く」規則を外しているので、千の位の 1 も「壱仟」と書かれる。金額表記はこの大字の前後に「金」「円也」を付けたもので、ツールは負数や小数の場合、大字と金額表記の欄を「対象外」と表示する。

漢数字 → 数字: 位取りと数字並びを1ループで読む

逆向きは、入力の書き方が一定しないのが難しい。

  • 位取り記法: 「二千二十六」「千万」「十二万三千」
  • 数字並び記法: 「二〇二六」(位を書かず数字を並べる)
  • 算用数字の混在: 「1億2千万」「12万」(全角数字も来る)

ツールはこれを、3つの変数を持つ1回の走査で読む。

変数 中身
num いま読んでいる数字の並び。数字が来るたびに num * 10 + d
section 万未満の区切りの合計。十・百・千が来たら num × 単位 を足す
total 万・億・兆が来たら (section + num) × 単位 を足して確定した分
1
2
3
4
5
6
7
8
if (ch in KDIG)   { num = num * 10n + BigInt(KDIG[ch]); hasNum = true; continue; }
if (ch in KSMALL) { section += (hasNum ? num : 1n) * BigInt(KSMALL[ch]); num = 0n; hasNum = false; continue; }
if (ch in KBIG) {
let head = section + num;
if (head === 0n) head = 1n; // 「万」単独は 1万
total += head * KBIG[ch];
section = 0n; num = 0n; hasNum = false; continue;
}

数字を num * 10 + d で積むので、「二〇二六」は単位が一度も来ないまま num = 2026 になる。位取り記法の「二千〇二十六」でも、〇 は num に 0 を積むだけで、次の「二」で num = 2 からやり直しになる(0 * 10 + 2)。2つの記法を分岐させずに同じ規則で読めるのはこのためである。

単位の前に数字がないとき(「千万」の千、「万」単独)は 1 として扱う。hasNum はこの判定のためにある。num が 0 かどうかでは「〇千」と「千」を区別できない。

実際に試す

Node.js 24.11.1 で確認した。ツールの2つの関数を、そのまま動く形にしたものである(ツール本体は Object.prototype.hasOwnProperty.call で表を引いている。ここでは短くするため in にした。下の入力では結果は同じ)。kansuji.js として保存して node kansuji.js で動く。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
const KDIG = { '〇':0,'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,
'壱':1,'弌':1,'弐':2,'貳':2,'参':3,'參':3,'肆':4,'伍':5,'陸':6,'漆':7,'捌':8,'玖':9 };
const KSMALL = { '十':10,'拾':10,'百':100,'佰':100,'陌':100,'千':1000,'仟':1000,'阡':1000 };
const KBIG = { '万':10000n,'萬':10000n,'億':100000000n,'兆':1000000000000n };
const ZEN = '0123456789';

// 10進文字列(16桁まで)→ 漢数字。daiji=true で大字
function numToKanji(numStr, daiji) {
let s = String(numStr).replace(/[,,\s]/g, '');
if (!/^\d+$/.test(s)) return null;
s = s.replace(/^0+(?=\d)/, '');
if (s.length > 16) return null;
if (s === '0') return '零';
const digits = daiji ? ['','壱','弐','参','四','五','六','七','八','九'] : ['','一','二','三','四','五','六','七','八','九'];
const units = daiji ? ['','拾','佰','仟'] : ['','十','百','千'];
const groups = daiji ? ['','萬','億','兆'] : ['','万','億','兆'];
let out = '', gi = 0;
while (s.length) {
const chunk = s.slice(-4); s = s.slice(0, -4);
let part = '';
for (let i = 0; i < chunk.length; i++) {
const d = +chunk[chunk.length - 1 - i];
if (!d) continue;
const dg = (d === 1 && i > 0 && !daiji) ? '' : digits[d]; // 通常表記は「一十」「一百」「一千」の一を省く
part = dg + units[i] + part;
}
if (part) out = part + groups[gi] + out;
gi++;
}
return out;
}

// 漢数字(位取り記法・数字並び記法・算用数字混在)→ 10進文字列。不正は null
function kanjiToNum(str) {
const s = String(str).replace(/[\s,,]/g, '');
if (!s) return null;
let total = 0n, section = 0n, num = 0n, hasNum = false, used = false;
for (let ch of s) {
const z = ZEN.indexOf(ch);
if (z >= 0) ch = String(z);
if (ch >= '0' && ch <= '9') { num = num * 10n + BigInt(ch); hasNum = true; used = true; continue; }
if (ch in KDIG) { num = num * 10n + BigInt(KDIG[ch]); hasNum = true; used = true; continue; }
if (ch in KSMALL) { section += (hasNum ? num : 1n) * BigInt(KSMALL[ch]); num = 0n; hasNum = false; used = true; continue; }
if (ch in KBIG) {
let head = section + num;
if (head === 0n) head = 1n; // 「万」単独は 1万
total += head * KBIG[ch];
section = 0n; num = 0n; hasNum = false; used = true; continue;
}
return null;
}
return used ? (total + section + num).toString() : null;
}

console.log('Node', process.version);
console.log('Number :', String(9999999999999999));
console.log('BigInt :', String(9999999999999999n));
console.log('MAX_SAFE :', numToKanji(String(Number.MAX_SAFE_INTEGER)));
for (const n of ['1010', '11000', '20260', '9999999999999999']) {
console.log(n.padEnd(16), numToKanji(n), '/', numToKanji(n, true));
}
for (const k of ['二〇二六', '二千〇二十六', '千万', '1億2千万', '12万', '壱萬', '万億', '金壱萬円也']) {
console.log(k, '->', kanjiToNum(k));
}

実行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Node v24.11.1
Number : 10000000000000000
BigInt : 9999999999999999
MAX_SAFE : 九千七兆千九百九十二億五千四百七十四万九百九十一
1010 千十 / 壱仟壱拾
11000 一万千 / 壱萬壱仟
20260 二万二百六十 / 弐萬弐佰六拾
9999999999999999 九千九百九十九兆九千九百九十九億九千九百九十九万九千九百九十九 / 九仟九佰九拾九兆九仟九佰九拾九億九仟九佰九拾九萬九仟九佰九拾九
二〇二六 -> 2026
二千〇二十六 -> 2026
千万 -> 10000000
1億2千万 -> 120000000
12万 -> 120000
壱萬 -> 10000
万億 -> 100010000
金壱萬円也 -> null

9999999999999999 は Number では 10000000000000000 に丸められるが、文字列と BigInt で通すと最後の桁まで残る。

この実装が受け付けてしまうもの・受け付けないもの

結果の最後の2行は、この実装の限界を示している。

  • 単位の順序は検査しない。「万億」は本来おかしな表記だが、100010000 を返す。万・億・兆が来るたびに足し込むだけで、前より小さい単位かどうかを見ていないためである。正しい表記だけを通したいなら、直前に使った大きな単位を覚えておき、それ以上の単位が来たら null を返す検査を足す。
  • 「金〜円也」は読まない。ツールは大字の金額表記を出力するが、入力として「金」「円」「也」は知らない文字なので null になる。読みたい場合は、前処理で /^金(.+)円也$/ を外してから渡す。

ツールでは、漢数字から戻した値が 16 桁を超えると「対応範囲を超えています」と表示する。対応範囲を数字→漢数字と揃えるためで、BigInt 自体は 17 桁以上でも正しく計算できる。

まとめ

  • 漢数字の「兆」の桁は Number.MAX_SAFE_INTEGER(約9007兆)を超える。数値に戻す処理は BigInt で積むか、文字列のまま扱う。
  • 数字→漢数字は 4 桁区切りで、「十・百・千の前の一を省く」「全部 0 の区切りには単位を付けない」の2点を押さえる。大字は一を省かない。
  • 漢数字→数字は num・section・total の3変数で、位取り記法と数字並び記法を同じループで読める。単位の前に数字がないときの 1 は、num の値ではなく「数字を読んだか」のフラグで判定する。

ツール: 漢数字変換ツール(ハシトシステム)。和暦との変換は 和暦変換ツール にある。


漢数字⇔数字変換をBigIntで書く 9999兆はNumberで壊れる
https://blog.hashito.biz/2026/10/06/kansuji-converter-bigint-9999-cho-two-notations/
著者
hashito
作成日
2026年10月6日
著作権