# 法令の計算をTypeScriptで実装する：失業保険（基本手当）の計算ロジックを純粋関数・改定テーブル・公表計算例のテストで作る

> 失業保険（雇用保険の基本手当）の計算ロジックを、TypeScript の純粋関数として実装する設計ガイド。告示の算式を整数比に直す理由、毎年8月に変わる上限額の持ち方、厚生労働省の公表計算例をそのままテストの期待値にする方法、入力をブラウザの外へ出さない計測設計までを、稼働中のアプリのコードで解説します。

- 公開日: 2026-09-25
- 著者: 友田 陽大
- タグ: TypeScript, 型安全, テスト, アーキテクチャ設計, 個人開発, Next.js, セキュリティ, 信頼性
- URL: https://tomodahinata.com/blog/statutory-calculation-typescript-unemployment-insurance-pure-functions-testing-guide
- カテゴリ: 型安全・バリデーション
- 総合ガイド: https://tomodahinata.com/blog/typescript-type-safety-discipline-zod-nevererror-no-any

## 要点

- 法令の計算は、React・ネットワーク・現在時刻を一切知らない純粋関数の層に閉じ込める。日付は引数で受け取り、表示文言も持たせない。そうすると、公表計算例をそのままユニットテストに流せる。
- 告示の `0.8w` や `70%` を浮動小数点のまま掛けると、切り捨ての直前に 0.0000…1 足りずに1円ずれることがある。再就職手当の公表例 `4,000円 × 90日 × 70%` は、`* 0.7` だと 251,999 円になる。金額は整数比（`* 70 / 100`）で計算する。
- 毎年8月1日に変わる上限額などは、ロジックから切り離した `tables.ts` に置き、改定日・次回改定日・出典URLを一緒に持たせる。制度改正で計算のしかたそのものが変わる場合は、離職日などの日付で分岐させる。この2種類は分けて扱う。
- 公表計算例を1つの配列にまとめ、検証ページの表示とテストの両方がその配列を読む。どちらか片方だけを直すことができない。たいしょくんでは30件をこの形で固定している。
- 「入力はブラウザから出ない」という約束は、型で縛れる所は型で、縛れない所（URL文字列）は値を作る側で削って守る。粗い区分だけは解析へ送っており、その範囲はプライバシーポリシーで公開している。

---

給与計算、税額、社会保険料、各種の給付金。業務システムを作っていると、**法令で決まっている計算**を実装する場面はよくあります。こうした計算は、仕様書がすでに存在する（条文と告示がそれにあたる）ぶん簡単に見えます。ところが実際に作ると、普通の業務ロジックよりずっと壊れやすいことが分かります。数字が毎年変わり、境界で1円ずれ、区分を1つ取り違えると、何のエラーも出ないまま結果だけが間違うからです。

この記事では、私が個人で開発・運用している[失業保険シミュレーター『たいしょくん』](https://taishokun.com/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_statutory_calc)の計算エンジンを実例にします。雇用保険の基本手当（いわゆる失業保険）の日額、所定給付日数、待期と給付制限を含む支給スケジュール、再就職手当を、TypeScript でどう組んだかを解説します。プロダクト全体の紹介は [/labs の「たいしょくん」の紹介ページ](/labs/taishokun)にまとめています。

> **この記事の位置づけ**：ソフトウェアの設計についての解説です。個別の受給資格や受給額の判断には使わないでください。実際の支給額はハローワークが決定します。ご自身のケースは管轄のハローワークに確認してください。
>
> **基準**：法定の数値は、厚生労働省「雇用保険の基本手当日額の変更」（令和8年7月31日 報道発表）に基づく **2026年8月1日以降** の値です。この値は毎年8月1日に改定されます。コードの出典は、たいしょくんのリポジトリ（Next.js 16.3.1 / TypeScript 6.0.3 / Zod 4.4.3 / Vitest 4.1.10、`package.json` で確認）です。

## 0. 結論：法令計算を実装するときの7つの判断

先に結論を表にまとめます。以降の章は、この表の1行ずつを実コードで説明していきます。

| 判断 | 採った形 | 採らなかった形と、その理由 |
| --- | --- | --- |
| 計算を置く層 | React・Next.js・`fetch`・`Date.now()` を import しない純粋関数の層 | コンポーネント内で計算すると、公表例をテストに流すたびに画面を描画する必要がある |
| 金額の演算 | 整数の円。告示の割合は整数比（`0.8w` → `w * 4 / 5`） | 浮動小数点のまま掛けると、`Math.floor` の直前で1円落ちることがある |
| 毎年変わる額 | `tables.ts` に集め、適用開始日・次回改定日・出典URLを持たせる | 年度別の定数（`WAGE_CAPS_2027`）を増やすと、どれが現行か分からなくなる |
| 計算方法を変える制度改正 | 離職日などの日付で分岐させる | 上書きすると、改正前に離職した人の計算が壊れる |
| 「該当しない」の表し方 | `null`（0 ではない）と、理由つきの判別共用体 | 0 円を返すと「計算の結果0円」と「対象外」を区別できない |
| 正しさの根拠 | 公表計算例をそのままテストの期待値にし、検証ページと同じ配列を読む | 実装と同じ式で期待値を作ったテストは、バグも一緒に再現する |
| 入力値の扱い | ブラウザ内で計算し、解析に渡す URL を削り、イベントのパラメータ型で生値を拒む | 「サーバーに送っていない」だけでは、解析タグ経由の漏れを防げない |

## 1. なぜ法令の計算は壊れやすいのか

失業保険の計算を例にとると、壊れ方にはおおむね4つのパターンがあります。

1. **毎年変わる**。基本手当日額の上限額・下限額や、給付率が切り替わる賃金日額は、毎年8月1日に改定されます。2026年は平均給与額が約2.7%上がったことを受けて、全区分が引き上げられました（厚生労働省の報道発表）。
2. **境界と丸めが仕様の一部**。基本手当日額は「1円未満切り捨て」です。0.0000…1 の誤差が切り捨ての直前にあると、それだけで1円ずれます。
3. **似た名前の区分が別の軸で動く**。「基本手当日額の上限を決める年齢区分」（30歳未満 / 30〜44歳 / 45〜59歳 / 60〜64歳）と、「所定給付日数表の年齢区分」（30歳未満 / 30〜34歳 / 35〜44歳 / 45〜59歳 / 60〜64歳）は、境界が違います。
4. **間違えてもエラーにならない**。区分を取り違えても、それらしい金額が出るだけです。例外もログも出ません。

4つ目がいちばん厄介です。だからこそ、**型で取り違えを防ぎ、公表されている数字でテストする**という2つの仕組みを最初から組み込んでおく必要があります。

## 2. 層の設計：計算は「何も知らない」純粋関数に閉じ込める

たいしょくんでは、法令の計算をすべて `src/domain/` に置いています。この層のルールは、ディレクトリ直下の `src/domain/CLAUDE.md` に明文化してあります。要点は次のとおりです。

- **`@/` の import 禁止**。React・Next.js・`fetch`・環境変数・`Date.now()` / `new Date()` も使わない。外部パッケージで使ってよいのは、入力スキーマを定義する `simulation-input.ts` の `zod` だけ。
- **日付は引数で受け取る**。「今日」を関数の中で取らない。
- **表示文言を持たない**。返すのは数値と列挙型だけ。日本語のラベルは UI 層の辞書（`src/i18n/ja.ts`）が持つ。
- **例外も Result 型も使わない**。「該当しない」は `null`、「受給資格がない」は理由つきの判別共用体、法定の範囲外の値は下限・上限へ丸める。

雇用保険の部分だけを見ても、`src/domain/employment-insurance/` には計算モジュールが10本と、共通の `tables.ts`・`types.ts` があります。どのモジュールにも同名の `*.test.ts` が隣に置いてあります（`ls src/domain/employment-insurance` で確認）。

```text
src/domain/
├── simulate.ts                 # 合成ルート：入力 → 全計算結果
├── simulation-input.ts         # 入力スキーマ（Zod）。型は z.infer で導出
├── shared/date.ts              # UTC の ISO 日付演算だけ
└── employment-insurance/
    ├── tables.ts               # 毎年変わる数値（このファイルだけを書き換える）
    ├── types.ts                # 区分・結果の型
    ├── basic-allowance.ts      # 基本手当日額
    ├── benefit-days.ts         # 所定給付日数
    ├── restriction.ts          # 給付制限
    ├── timeline.ts             # 待期・給付制限・28日サイクルを実日付へ展開
    ├── reemployment.ts         # 再就職手当
    └── …（eligibility / claim-period / cashflow / elderly-lump-sum など）
```

この形にすると、**画面を一切描画せずに公表計算例をテストへ流せます**。後述の検証テストが速く、しかも決定的に動くのは、この層に時計もネットワークも無いからです。雇用保険ドメインと検証ページのテスト（11ファイル・298件）は、手元で 2.24 秒で通りました（`npx vitest run src/content/verification.test.ts src/domain/employment-insurance`、2026-09-25 実行）。

## 3. 基本手当日額：告示の算式を「整数比」に書き直す

### 3.1 仕様：年齢区分ごとの算式

基本手当日額は、離職前6か月の賃金から求めた**賃金日額**に給付率を掛けて求めます。給付率は賃金が低いほど高く、80%から50%（60〜64歳は45%）まで逓減します。2026年8月1日以降の区分は次のとおりです（厚生労働省「基本手当日額の計算式及び金額（令和8年8月1日～）」）。

| 賃金日額 w（30〜44歳の例） | 基本手当日額 y |
| --- | --- |
| 3,203円以上 5,480円未満 | y = 0.8w |
| 5,480円以上 13,490円以下 | y = 0.8w − 0.3{(w − 5480)/(13490 − 5480)}w |
| 13,490円超 16,540円以下 | y = 0.5w |
| 16,540円超 | y = 8,270 |

年齢区分ごとの上限額は次のとおりです（同 報道発表。2026年8月1日から）。

| 離職時の年齢 | 賃金日額の上限 | 基本手当日額の上限 |
| --- | --- | --- |
| 30歳未満 | 14,900円 | 7,450円 |
| 30歳以上45歳未満 | 16,540円 | 8,270円 |
| 45歳以上60歳未満 | 18,220円 | 9,110円 |
| 60歳以上65歳未満 | 17,400円 | 7,830円 |

下限額は全年齢共通で 2,562円です。報道発表は、これが最低賃金日額（地域別最低賃金の全国加重平均 1,121円 × 20 ÷ 7）に給付率80%を掛けた値だと説明しています。

### 3.2 実装：分母を払って、割り算を最後の1回にする

この告示の式を、たいしょくんでは次のように書いています（`src/domain/employment-insurance/basic-allowance.ts` から抜粋）。

```ts
/**
 * 逓減区間の給付額。告示の式から分母を払い、整数の比に変形する。
 *   60歳未満:  y = w × (8(w2−w1) − 3(w−w1))  / (10(w2−w1))
 *   60〜64歳:  y = w × (16(w2−w1) − 7(w−w1)) / (20(w2−w1))
 */
function taperedAmount(wage: number, tier2: number, isAge60to64: boolean): number {
  const span = tier2 - RATE_TIER1_CEILING;
  const over = wage - RATE_TIER1_CEILING;
  if (isAge60to64) {
    const byTaper = (wage * (16 * span - 7 * over)) / (20 * span);
    // 60〜64歳だけは告示上もう一本の式との min を取る。
    const byFloorLine = wage / 20 + (2 * tier2) / 5;
    return Math.min(byTaper, byFloorLine);
  }
  return (wage * (8 * span - 3 * over)) / (10 * span);
}
```

ポイントは2つあります。

**1つ目は、式を整数比に変形していること。** `0.8w` は `w * 4 / 5`、`0.45w` は `w * 9 / 20` と書きます。掛け算を先に整数のまま済ませ、割り算を最後の1回だけにする形です。理由は次の章で、数字を使って示します。

**2つ目は、60〜64歳の `min`。** 厚生労働省の計算式の資料（「基本手当日額の計算式及び金額」参考1の3.）をよく読むと、60歳以上65歳未満の逓減区間には `y = 0.05w + (12120 × 0.4)` という**もう1本の式**があり、「のいずれか低い方の額」と書かれています。コードの `wage / 20 + (2 * tier2) / 5` がこれにあたります。

この行は実装し忘れやすいうえに、境界値のテストでは見つかりません。2本の式は、逓減の終わる賃金日額 12,120円でちょうど交わり、どちらも 5,454円になるからです。差が出るのは区間の途中で、たとえば賃金日額 10,000円なら、逓減式だけだと 5,617円、`min` を取ると 5,348円になり、269円の過大計算になります（上の2つの式に代入して確認できます）。たいしょくんのテストには、2本の式が入れ替わる点（賃金日額 7,589円）で低い方の式が採用されることを確かめる回帰テストを、境界値とは別に置いています。

計算の順序も告示どおりに固定しています。

```ts
export function basicAllowanceFor(
  wageDailyRaw: number,
  band: WageCapAgeBand,
  rateBand: BenefitRateBand,
): BasicAllowanceResult {
  const caps = WAGE_CAPS[band];
  const isAge60to64 = rateBand === 'age60to64';

  // 1. 賃金日額を下限・上限へ丸める
  let wageDaily = wageDailyRaw;
  let capApplied: BasicAllowanceResult['capApplied'] = 'none';
  if (wageDaily < WAGE_DAILY_FLOOR) {
    wageDaily = WAGE_DAILY_FLOOR;
    capApplied = 'lower';
  } else if (wageDaily > caps.wageDaily) {
    wageDaily = caps.wageDaily;
    capApplied = 'upper';
  }

  // 2. 給付率を適用する
  const tier2 = isAge60to64 ? RATE_TIER2_CEILING.age60to64 : RATE_TIER2_CEILING.under60;
  let exact: number;
  if (wageDaily < RATE_TIER1_CEILING) {
    exact = (wageDaily * 4) / 5;
  } else if (wageDaily <= tier2) {
    exact = taperedAmount(wageDaily, tier2, isAge60to64);
  } else {
    exact = isAge60to64 ? (wageDaily * 9) / 20 : wageDaily / 2;
  }

  // 3. 1円未満を切り捨てる
  const dailyAmount = Math.floor(exact);
  return { wageDaily, wageDailyRaw, capApplied, dailyAmount, rate: dailyAmount / wageDaily };
}
```

戻り値には、丸める前の `wageDailyRaw` と、どちらの端で丸めたかを示す `capApplied` も含めています。画面で「上限が適用されました」と説明するには、この情報が要ります。計算した側が判断材料も一緒に返すようにしておけば、UI 側で同じ判定を書き直す必要がありません。

## 4. 浮動小数点で1円ずれるのは本当か：実測

「金額に浮動小数点を使うな」はよく言われますが、実際にどのくらいずれるのかを測りました。

### 4.1 再就職手当の公表例は、そのまま書くと1円ずれる

ハローワーク「再就職手当のご案内」（LL080801保02）には、`4,000円 × 90日 × 70% = 252,000円` という計算例が載っています。これを JavaScript でそのまま書くとどうなるかを確かめます。

```bash
node -e "console.log(4000*90*0.7, Math.floor(4000*90*0.7))"
# 251999.99999999997 251999
```

0.7 は二進数の浮動小数点で正確に表せません（ECMAScript の Number は IEEE 754 倍精度）。そのため積がわずかに小さくなり、切り捨てると公表値より1円少なくなります。たいしょくんの `reemployment.ts` は、支給率を `0 | 60 | 70` の整数（百分率）で持ち、最後に1回だけ割っています。

```ts
// 支給率は 0.7 ではなく 70/100 として掛ける。
return {
  rate: reemploymentRate(remainingDays, prescribedDays),
  cappedDailyAmount,
  amount: percent === 0 ? 0 : Math.floor((cappedDailyAmount * remainingDays * percent) / 100),
};
```

どのくらいの頻度で起きるかも数えました。基本手当日額 2,562〜6,745円（再就職手当の算定上限まで）× 支給残日数 1〜330日 × 支給率 60%/70% の全組み合わせ 2,761,440 通りのうち、`Math.floor(d * r * 0.7)` と `Math.floor(d * r * 70 / 100)` の結果が食い違ったのは **72,019 通り（2.61%）** でした。

```bash
node -e "
let tot=0,bad=0;
for(let d=2562;d<=6745;d++)for(let r=1;r<=330;r++)for(const p of [60,70]){
  tot++; if(Math.floor(d*r*(p/100))!==Math.floor(d*r*p/100)) bad++;
}
console.log(tot,bad,(bad/tot*100).toFixed(2)+'%');"
# 2761440 72019 2.61%
```

50件に1件を超える割合で1円ずれるので、偶然の一致に頼れる頻度ではありません。

### 4.2 基本手当日額の式では、今回の総当たりでは差が出なかった

公平のために書いておくと、基本手当日額の逓減式（60歳未満）を浮動小数点のまま書いた場合も、同じように総当たりしました。賃金日額 5,480〜13,490円の整数すべてと、月額賃金を30で割った端数つきの賃金日額（月額 164,400〜404,700円の1円刻み）で比べたところ、整数比の形と結果が食い違う入力は**1件もありませんでした**。60〜64歳の式（`min` を含む）も、整数の賃金日額 5,480〜12,120円で試した範囲では同じでした。

実は、たいしょくんのコードのコメントには「浮動小数点のまま評価すると、w = w2 のような境界で 6745 が 6744.999… に落ちて1円ずれる」と書いてあります。ところが今回試した式の書き方では、この現象は再現しませんでした。浮動小数点の誤差は、式の書き方や演算の順序で出たり消えたりします。「この書き方なら大丈夫だった」という経験則は、式を少し書き換えただけで通用しなくなります。**すべての金額計算を整数比に揃えておけば、この種の不具合を式ごとに確かめる必要自体がなくなります**。そのほうが規則として保ちやすい、というのが整数比を選ぶ本当の理由です（ずれた実例は、4.1 の再就職手当で確認できます）。

| 書き方 | 再就職手当 `4000×90×70%` | 危険度 |
| --- | --- | --- |
| `4000 * 90 * 0.7` | 251,999（公表値と1円差） | 高：2.61% の組み合わせでずれる |
| `4000 * 90 * (70 / 100)` | 251,999 | 高：`70 / 100` の時点で 0.7 と同じ値になる |
| `(4000 * 90 * 70) / 100` | 252,000 | 低：整数の積が 2^53 未満なら積は正確 |
| decimal ライブラリ | 252,000 | 低：ただし依存が増え、バンドルも大きくなる |

たいしょくんは decimal ライブラリを入れていません。円単位の積は `Number.MAX_SAFE_INTEGER`（約9,007兆）に遠く届かないので、整数比で書くだけで足ります。

## 5. 毎年変わる数値：`tables.ts` と「日付で分岐する改正」

### 5.1 額の改定は、1つの表をその場で書き換える

毎年8月1日に変わる数値は、ロジックから切り離して `tables.ts` に集めています（`src/domain/employment-insurance/tables.ts` から抜粋）。

```ts
/**
 * 雇用保険の制度テーブル。毎年8月1日に改定されるため、
 * ロジックから分離した「データ」として持つ。
 *
 * 出典: 厚生労働省「雇用保険の基本手当日額の変更」(令和8年7月31日 報道発表)
 *   https://www.mhlw.go.jp/stf/newpage_74837.html
 * 一次資料アクセス日: 2026-08-17
 */
export const SCHEDULE = {
  validFrom: '2026-08-01',
  /** 次回改定予定。UI の「次回改定」表示に使う。 */
  nextRevision: '2027-08-01',
  sourceUrl: 'https://www.mhlw.go.jp/stf/newpage_74837.html',
} as const;

/** 賃金日額の下限(全年齢共通)。地域別最低賃金の全国加重平均 1,121円 × 20 ÷ 7 に由来。 */
export const WAGE_DAILY_FLOOR = 3203;

/** 基本手当日額の下限(全年齢共通)= 賃金日額下限 × 80%。 */
export const DAILY_AMOUNT_FLOOR = 2562;

export const WAGE_CAPS: Readonly<
  Record<WageCapAgeBand, { readonly wageDaily: number; readonly dailyAmount: number }>
> = {
  lt30: { wageDaily: 14900, dailyAmount: 7450 },
  a30to44: { wageDaily: 16540, dailyAmount: 8270 },
  a45to59: { wageDaily: 18220, dailyAmount: 9110 },
  a60to64: { wageDaily: 17400, dailyAmount: 7830 },
};
```

決めごとは3つです。

1. **定数の横に出典とアクセス日を書く**。値を疑うことになったとき、コメントから一次資料に直接たどれます。
2. **年度別の定数を増やさない**。`WAGE_CAPS_2027` のような同名の定数を足さず、同じ定数を書き換えます。履歴は Git が持っています。
3. **`SCHEDULE` を画面に出す**。「この試算は 2026-08-01 からの額で計算しています」と利用者に見せます。次回改定日が過ぎても表を更新していなければ、利用者の目で気づけます。

### 5.2 「全年度の表を持つ」設計との比較

ここは要件によって正解が変わるので、比べておきます。

| 観点 | 1つの表を書き換える（たいしょくん） | 適用開始日つきの表を年度分持つ |
| --- | --- | --- |
| 向いている用途 | 「今辞めたらいくらか」を試算する消費者向けツール | 過去の離職日で再計算する業務システム・監査 |
| ルックアップ | 定数をそのまま参照するだけ | `validFrom <= 基準日` の最新行を探す関数が要る |
| テスト | 現行の公表値だけでよい | 年度ごとに公表値が要る（古い資料が入手できなくなることもある） |
| 失敗のしかた | 改定を忘れると古い額のまま（`nextRevision` の表示で気づける） | 基準日を取り違えると、黙って別の年度の額になる |

業務システムなら、後者を選ぶ場面が多いはずです。その場合も、**基準日を関数の引数で受け取る**という第2章の原則がそのまま生きます。関数の中で `new Date()` を呼んでいると、年度の表を引く基準が実行した時刻になってしまうからです。

### 5.3 計算のしかたが変わる改正は、日付で分岐させる

額の改定とは別に、**計算のしかたそのものが変わる改正**もあります。これは上書きしてはいけません。たとえば、自己都合離職の給付制限は、2025年4月1日以降の離職から原則2か月が原則1か月に短縮されました。改正前に離職した人には、今でも2か月が適用されます。

```ts
/** 給付制限が「原則2か月」から「原則1か月」へ短縮された改正の施行日。離職日で判定する。 */
export const RESTRICTION_REFORM_DATE = '2025-04-01';

// restriction.ts
const months = input.separationDate >= RESTRICTION_REFORM_DATE ? 1 : 2;
return { months, reason: 'voluntaryStandard' };
```

日付は `YYYY-MM-DD` 形式の文字列のまま比較しています。桁数が固定の ISO 形式なら、文字列の大小比較がそのまま日付の前後関係になります。`Date` オブジェクトを経由しないので、タイムゾーンが入り込む余地もありません。

### 5.4 表から導ける値は、書き写さずに導く

所定給付日数として存在しうる値の一覧（90, 120, 150, …, 360日）は、3つの日数表から計算して作っています。

```ts
export const PRESCRIBED_BENEFIT_DAYS: readonly number[] = [
  ...new Set(
    [
      ...Object.values(GENERAL_BENEFIT_DAYS),
      ...Object.values(QUALIFIED_BENEFIT_DAYS).flatMap((row) => Object.values(row)),
      ...Object.values(HARDSHIP_BENEFIT_DAYS).flatMap((row) => Object.values(row)),
    ].filter((days): days is number => days !== null),
  ),
].sort((left, right) => left - right);
```

一覧を手で書き写すと、表だけを直した日に一覧が古いまま残ります。そうなっても何も壊れず、記事や画面が古い日数を並べ続けるだけです。**毎年書き換える表から派生する値は、すべてコードで導出する**。法令計算では、DRY 原則がそのまま正しさの問題になります。

## 6. 型：区分の取り違えを、コンパイラに止めてもらう

### 6.1 似た年齢区分は、別の型にする

第1章で触れた2つの年齢区分は、別の型にしています（`types.ts`）。

```ts
/** 所定給付日数表の年齢区分。基本手当日額の上限区分とは境界が異なるので混同しない。 */
export type BenefitDaysAgeBand = 'lt30' | 'a30to34' | 'a35to44' | 'a45to59' | 'a60to64';

/** 賃金日額・基本手当日額の上限区分。所定給付日数の年齢区分とは別物。 */
export type WageCapAgeBand = 'lt30' | 'a30to44' | 'a45to59' | 'a60to64';

/** 給付率の区分。賃金日額の上限区分とは別の軸で動く。 */
export type BenefitRateBand = 'general' | 'age60to64';
```

`WAGE_CAPS[band]` の `band` に `BenefitDaysAgeBand` を渡すと、`'a30to34'` が存在しないのでコンパイルエラーになります。

さらに `BenefitRateBand` を上限区分と別の型にしている理由は、65歳以上が対象の**高年齢求職者給付金**にあります。雇用保険法37条の4第2項は、賃金日額の上限として17条4項2号ニ（30歳未満の区分）を指定しています。一方で、給付率の読み替え（16条2項）は「六十歳以上六十五歳未満」の人にしか適用されません。つまり65歳以上は、「30歳未満の上限 × 一般の給付率」という、基本手当のどの年齢区分とも一致しない組み合わせになります。年齢を1つ渡すだけの関数では、これを表現できません。そこで `basicAllowanceFor(wage, band, rateBand)` のように、2つの決定を別の引数にしています。

### 6.2 区分の一覧は配列を1つだけ定義し、型はそこから導く

離職理由の区分は、配列を唯一の定義にしています。

```ts
export const SEPARATION_REASONS = [
  'voluntary',               // 一般の離職者（正当な理由のない自己都合など）
  'company',                 // 特定受給資格者（倒産・解雇など）
  'specificReason',          // 特定理由離職者のうち、有期契約の期間満了型
  'specificReasonJustCause', // 特定理由離職者のうち、正当な理由のある自己都合
  'grossMisconduct',         // 重責解雇
] as const;

export type SeparationReason = (typeof SEPARATION_REASONS)[number];

// simulation-input.ts
const separationReasonSchema = z.enum(SEPARATION_REASONS);
```

Zod の入力スキーマ、共有 URL の復元、フォームの選択肢が、どれもこの1つの配列を読みます。以前は同じ並びが3か所に手書きされていて、1か所直し忘れても型チェックは通ってしまっていた、とコードのコメントに記録されています。

特定理由離職者を2つに分けているのは、**所定給付日数の優遇措置の対象が期間満了型だけ**だからです（施行規則附則18条）。1つにまとめると、正当な理由のある自己都合の人の給付日数を、一般の表より多く表示してしまいます。

### 6.3 `null` は 0 ではない

```ts
/** 受給資格の判定結果。満たさない場合は理由を返す(黙って0円にしない)。 */
export type EligibilityResult =
  | { readonly eligible: true }
  | {
      readonly eligible: false;
      readonly reason: 'insufficientInsuredMonths' | 'ageOutOfRange' | 'benefitDaysUnavailable';
    };
```

所定給付日数の表には、30歳未満で被保険者期間20年以上のように、理屈の上で起こりえない組み合わせがあります。そのセルは 0 ではなく `null` にしています。0日と書くと、「受給できる日数が0日」という別の意味に読めてしまうからです。対象外かどうかは型で区別し、その理由は UI が辞書から説明文を選んで表示します。

## 7. 公表計算例をテストの期待値にする

### 7.1 検証ページとテストが、同じ配列を読む

たいしょくんには、公的機関が公表している計算例と、アプリの計算結果を並べた[計算の検証ページ](https://taishokun.com/verification?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_statutory_calc)があります。このページの表示元である `src/content/verification.ts` は、テストの入力も兼ねています。

```ts
const MHLW_BENEFIT_PDF: VerificationSource = {
  label: '厚生労働省「雇用保険の基本手当日額の変更」添付資料(令和8年7月31日)',
  url: 'https://www.mhlw.go.jp/content/11607000/001729233.pdf',
};

export const VERIFICATION_GROUPS: readonly VerificationGroup[] = [
  {
    id: 'basic-allowance',
    title: '失業保険の基本手当日額',
    cases: [
      { id: 'ba-6000', condition: '賃金日額 6,000円(60歳未満)', expected: 4683, unit: '円', source: MHLW_BENEFIT_PDF },
      { id: 'ba-tier2-end', condition: '賃金日額 13,490円(逓減が終わる金額・60歳未満)', expected: 6745, unit: '円', source: MHLW_LEAFLET },
      { id: 'ba-60-tier2-end', condition: '62歳・賃金日額 12,120円(逓減が終わる金額・60〜64歳)', expected: 5454, unit: '円', source: MHLW_LEAFLET },
      // …
    ],
  },
  // 再就職手当・退職金の税・高年齢求職者給付金・傷病手当金・社会保険料
];
```

テスト側（`src/content/verification.test.ts`）は、ケースIDと実際の計算関数を対応づけた表を持ち、全ケースを計算エンジンに流します。

```ts
const CALCULATORS: Readonly<Record<string, () => number>> = {
  'ba-6000': () => calculateBasicAllowance(6000, 40)!.dailyAmount,
  'ba-tier2-end': () => calculateBasicAllowance(13_490, 40)!.dailyAmount,
  'ba-60-tier2-end': () => calculateBasicAllowance(12_120, 62)!.dailyAmount,
  're-90-90': () => calculateReemploymentAllowance(4000, 90, 90, 40).amount,
  // …
};

describe('/verification に掲載している突合結果', () => {
  for (const group of VERIFICATION_GROUPS) {
    describe(group.title, () => {
      for (const testCase of group.cases) {
        it(`${testCase.condition} → ${testCase.expected.toLocaleString('ja-JP')}${testCase.unit}`, () => {
          const calculator = CALCULATORS[testCase.id];
          expect(calculator, `${testCase.id} に対応する計算が定義されていない`).toBeDefined();
          expect(calculator!()).toBe(testCase.expected);
        });
      }
    });
  }

  it('掲載しているすべてのケースに計算が結びついている', () => {
    const caseIds = VERIFICATION_GROUPS.flatMap((group) => group.cases.map((c) => c.id));
    // 逆方向も確認する。計算だけ残ってページから消えた項目を検出するため。
    expect(Object.keys(CALCULATORS).sort()).toEqual([...caseIds].sort());
  });
});
```

この仕組みでは、**テストを通っていない検証結果をページに載せられません**。ページにケースを足せば、対応する計算が無い限りテストが落ちます。逆に、計算だけが残ってページから消えた項目も、最後のテストで検出されます。信頼性を主張するページそのものが、主張の裏付けを構造として持っていることになります。

掲載しているケースは30件です（`grep -cE "^        id: '" src/content/verification.ts` で30。本番の `/verification` の表示も30件）。

| グループ | 件数 | 出典 |
| --- | --- | --- |
| 基本手当日額 | 10 | 厚生労働省の報道発表の添付資料、受給者向けリーフレット |
| 再就職手当 | 3 | ハローワーク「再就職手当のご案内」 |
| 退職金にかかる税金 | 7 | 国税庁 No.2732、横浜市の住民税の計算例 |
| 高年齢求職者給付金 | 5 | 雇用保険法37条の4、業務取扱要領 |
| 傷病手当金の日額 | 3 | 全国健康保険協会 |
| 退職後の社会保険料 | 2 | 全国健康保険協会 |

### 7.2 公表例だけでは足りない：境界値を足す

厚生労働省の報道発表に載っている計算例は、賃金日額 6,000円と 9,000円の2つだけです。これだけだと、逓減区間の真ん中しか検査できません。そこで、給付率が切り替わる境界（5,479円 / 5,480円 / 13,490円、60〜64歳の 12,120円）と上限・下限を足し、`it.each` で並べています。どの境界の値も、リーフレットや添付資料の図に印刷されている数字です。

```ts
describe('基本手当日額 — 給付率区分の境界値', () => {
  it.each([
    [WAGE_DAILY_FLOOR, 40, DAILY_AMOUNT_FLOOR],
    [5479, 40, 4383],
    [5480, 40, 4384],
    [13490, 40, 6745],
  ])('60歳未満: 賃金日額 %i円 → %i円', (wage, age, expected) => {
    expect(dailyAmountOf(wage, age)).toBe(expected);
  });
});
```

### 7.3 期待値を実装と同じ式で作らない

`src/domain/CLAUDE.md` には、テストの期待値の出どころを次の2つに限る、と書いてあります。

1. 公表計算例から**そのまま書き写す**（出典とケース番号を添える）
2. 実装とは**独立に手で導く**

実装と同じアルゴリズムで期待値を計算し直すテストは禁止です。そうしたテストは、バグがあってもバグごと再現して通ってしまうからです。日付の計算も同じで、支給スケジュールのテストでは、待期満了日や支給開始日を手で数えた日付の文字列で書いています。

```ts
it('待期は受給資格決定日を含めて7日間なので満了日は決定日+6日', () => {
  expect(timeline().waitingPeriodEnd).toBe('2026-09-07'); // 決定日 2026-09-01
});

it('給付制限1か月は待期満了日から起算する', () => {
  const result = timeline();
  expect(result.restrictionEnd).toBe('2026-10-07');
  expect(result.benefitStart).toBe('2026-10-08');
});
```

カバレッジの基準は statements 100 / functions 100 / lines 100 / branches 95 です（`vitest.config.mts` の `thresholds`）。ただ、法令計算で本当に効いているのは、カバレッジの数字よりも**期待値がどこから来たか**のほうです。カバレッジが100%でも、期待値を実装から作っていれば何も保証されません。

### 7.4 日付：UTC の ISO 文字列だけで計算する

支給スケジュールは、待期7日・給付制限・28日ごとの失業認定を実際の日付に展開したものです。日付の演算はすべて `shared/date.ts` の UTC 計算に集めています。利用者の端末がどのタイムゾーンにあっても、日本の制度上の日付が1日ずれないようにするためです。

月の加算には、法令計算ならではの落とし穴があります。1月31日の1か月後は2月31日ではありません。`addMonths` は、加算先の月に同じ日が無ければその月の末日に丸めます。また、支給対象日を受給期間の満了日で打ち切るときは、**振込日ではなく支給対象日で**切っています（雇用保険法20条1項）。振込日で切ると、受給期間内の日に対する振込が満了日の後になった場合を落としてしまい、支給額が過小に出ます。

## 8. プライバシー：入力値をブラウザの外へ出さない

失業保険の試算に入力するのは、年齢・月収・退職日・離職理由です。どれも他人に知られたくない情報です。たいしょくんは計算をすべてブラウザ内で行い、サーバーには送りません。ルーティングは、問い合わせフォームの `POST /api/contact` を除いてすべて静的に生成しています（`src/app` 配下の route handler は `llms.txt`・`feed.xml` が `force-static`、動的なのは `api/contact` だけ）。

ただし、**サーバーに送っていないだけでは約束を守りきれません**。アクセス解析のタグが、URL などを経由して外へ持ち出す経路があるからです。

### 8.1 URL：入力はフラグメントに置き、解析に渡す前に削る

入力条件を共有するための URL では、条件をフラグメント（`#` 以降）に置いています。RFC 3986 では、フラグメントはクライアント側で解釈される部分とされており、HTTP リクエストでサーバーへは送られません。ところが、GA4 に送る `page_location` はただの文字列です。`location.href` をそのまま渡すと、フラグメントも一緒に送られてしまいます。実際にこの経路から漏れたことがあった、とコードのコメントに残っています。そこで、解析に渡す前に URL を削っています（`src/lib/analytics/tracked-url.ts`）。

```ts
/** GA4 の参照元レポートが読むキー。ここに無いクエリは計測へ出さない。 */
const TRACKED_QUERY_KEYS: readonly string[] = [
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
  'gclid', 'fbclid', 'msclkid',
];

export function toTrackedUrl(url: string, base: string): string {
  let parsed: URL;
  try {
    parsed = new URL(url, base);
  } catch {
    return new URL(base).origin + new URL(base).pathname;
  }

  const kept = new URLSearchParams();
  for (const key of TRACKED_QUERY_KEYS) {
    const value = parsed.searchParams.get(key);
    if (value !== null) kept.set(key, value);
  }

  const query = kept.toString();
  // フラグメントは組み立て直さない。ここが入力値の載る場所だった。
  return `${parsed.origin}${parsed.pathname}${query === '' ? '' : `?${query}`}`;
}
```

クエリは**許可リスト**方式にしています。禁止リストにすると、将来だれかが URL に新しい状態を載せたとき、それが既定で外へ出てしまいます。許可リストなら、既定で出ません。

### 8.2 イベント：生値を載せる場所を、型から無くす

解析イベントのパラメータ型（`AnalyticsEventParams`）には、年齢・月収・退職日・受給額を入れるフィールドがありません。載せたくなったらコンパイルエラーで止まる、という設計です。

一方で、正直に書いておくべき例外があります。たいしょくんは、**入力を粗い区分に丸めたもの**を解析の user property として送っています。年齢は所定給付日数表の区分、月収は10万円刻み、受給総額は桁ごとの区分です。変換は `src/lib/analytics/input-profile.ts` の `toInputProfile` が行い、その戻り値の型は閉じた語彙（文字列リテラルの共用体）だけでできています。生の値を混ぜることはできません。要配慮個人情報にあたる就職困難者の区分は、この関数の戻り値に含めず、明示的な同意を得た場合だけ別の経路で送っています。区分の内容は、たいしょくんのプライバシーポリシーに一覧で公開しています。

「何も送っていない」と書くほうが簡単です。それでも実態と違うことは書かず、送っている範囲を型で閉じて、その範囲を公開する。このほうが監査もでき、あとで説明にも困りません。

### 8.3 CSP と Zod：`jitless` は最初に設定する

たいしょくんは本番の CSP で `'unsafe-eval'` を許可していません（`src/lib/security/csp.ts`。開発時だけ許可）。Zod 4 は、スキーマを高速に検証するために `new Function("")` が使えるかどうかを最初に試します。eval を禁止する CSP のもとでは、この試行は例外になり、Zod 自身は握りつぶして通常の経路に切り替えます。ただ、ブラウザには CSP 違反として記録が残ります（Zod のソース `v4/core/util.ts` の `allowsEval` のコメントにも、この挙動が書かれています）。

```ts
import { z } from 'zod';

// Zod の JIT を無効化する。eval を許可しない CSP で違反が記録されないようにする。
z.config({ jitless: true });
```

`allowsEval` の判定結果はキャッシュされます。そのため、この設定は**最初のパースより前**に実行されなければ効きません。たいしょくんでは、Zod を使うモジュールの冒頭で毎回これを呼ぶ決まりにしています（`simulation-input.ts` と問い合わせフォームのスキーマ）。

## 9. 落とし穴チェックリスト

実装・レビューの際に確認する項目をまとめます。

| 確認項目 | 見落とすとどうなるか |
| --- | --- |
| 割合を整数比で掛けているか（`* 0.7` になっていないか） | 公表値から1円ずれる（再就職手当では 2.61% の組み合わせ） |
| 60〜64歳の逓減区間で、もう1本の式との `min` を取っているか | 60〜64歳で過大に計算される（賃金日額10,000円で269円）。境界値のテストでは見つからない |
| 上限額の年齢区分と、給付日数表の年齢区分を別の型にしているか | 30〜34歳と35〜44歳の区別が消える |
| 再就職手当の算定上限（6,745円 / 5,454円）を、基本手当の上限と混同していないか | 再就職手当が過大に出る |
| 表の値に出典・適用開始日・次回改定日があるか | 改定を忘れても誰も気づかない |
| 計算方法を変える改正を、上書きではなく日付で分岐させているか | 改正前に離職した人の計算が壊れる |
| 対象外を 0 でなく `null` や判別共用体で返しているか | 「0円」と「対象外」の区別がつかない |
| テストの期待値が、公表値か手計算か | 実装の式で作った期待値はバグを検出しない |
| 関数の中で `new Date()` を呼んでいないか | テストが実行日で変わり、年度の判定もずれる |
| 解析に渡す URL からフラグメントと余分なクエリを削っているか | 入力値が解析サービスへ送られる |

## 10. このコードが動いているアプリ

ここで紹介したコードは、**失業保険・退職金の税金・退職後の社会保険料**をまとめて試算する無料のブラウザツール、[失業保険シミュレーター『たいしょくん』](https://taishokun.com/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_statutory_calc)で動いています。基本手当日額と所定給付日数から、待期7日と給付制限を反映した月ごとの支給スケジュール、再就職手当、退職金の税額、退職後の国民健康保険・住民税までを、入力をブラウザから出さずに計算します。個人開発としての設計判断は [/labs の紹介ページ](/labs/taishokun)にまとめています。

繰り返しになりますが、ツールの計算結果もこの記事の説明も、個別の受給額を保証するものではありません。ご自身の受給資格や金額は、管轄のハローワークで確認してください。

関連して、境界での型の検証の全体像は[TypeScript の型安全を徹底する設計規律](/blog/typescript-type-safety-discipline-zod-nevererror-no-any)に、Zod の使い方は[Zod の実践ガイド](/blog/zod)に、CSP の組み方は[Next.js のセキュリティヘッダーと CSP の実装ガイド](/blog/nextjs-security-headers-csp-nonce-middleware-guide)にまとめています。

給与・税・社会保険・給付金のように、**法令で決まる計算を業務システムに組み込む**開発も、同じ設計（純粋関数の層、出典つきの表、公表例によるテスト、入力値を外に出さない計測）でお引き受けしています。進め方は[サービス内容](/services)をご覧ください。
