Payroll, taxes, social insurance premiums, public benefits: if you build business software, sooner or later you implement a calculation defined by law. It looks easy, because the spec already exists — the statute and the ministerial notice are the spec. In practice these calculations break more easily than ordinary business logic. The numbers change every year, boundaries drift by one yen, and picking the wrong category produces a plausible wrong answer with no error at all.
This article uses the calculation engine of Taishokun, a Japanese unemployment benefit calculator (『たいしょくん』) that I build and run on my own, as the worked example. It shows how the daily amount of Japan's basic allowance (what people call unemployment insurance), the number of benefit days, the payout schedule including the waiting period and the benefit restriction, and the re-employment allowance are structured in TypeScript. The app's UI is Japanese only; the design lessons below are language-agnostic. An overview of the product is on the Taishokun write-up in Labs.
What this is and isn't: a software design article. Do not use it to judge anyone's eligibility or benefit amount — actual payments are decided by Hello Work, Japan's public employment service. Check your own case with your local Hello Work office.
Baseline: statutory figures are those in force from August 1, 2026, per the Ministry of Health, Labour and Welfare (MHLW) press release of July 31, 2026. They are revised every August 1. Code comes from the Taishokun repository (Next.js 16.3.1 / TypeScript 6.0.3 / Zod 4.4.3 / Vitest 4.1.10, confirmed in
package.json).
0. The short version: seven decisions
Here is the conclusion up front. Each section after this walks through one row with real code.
| Decision | What we did | What we didn't do, and why |
|---|---|---|
| Where the math lives | A layer of pure functions that imports no React, Next.js, fetch, or Date.now() | Computing inside components means rendering UI every time you test a published example |
| Money arithmetic | Integer yen; statutory rates as integer ratios (0.8w → w * 4 / 5) | Floating-point products can land just below an integer, and Math.floor drops a yen |
| Values that change yearly | Collected in tables.ts with effective date, next revision date, and source URL | Year-suffixed constants (WAGE_CAPS_2027) leave nobody sure which one is current |
| Reforms that change the method | Branch on a date such as the separation date | Overwriting breaks the calculation for people who left before the reform |
| "Does not apply" | null (not 0) and discriminated unions carrying a reason | Returning 0 yen conflates "the answer is zero" with "not applicable" |
| Evidence of correctness | Published worked examples used verbatim as expected values, in the same array the verification page reads | Tests whose expected values reuse the implementation's formula reproduce the bug |
| User input | Computed in the browser; URLs stripped before analytics; event parameter types reject raw values | "We don't send it to our server" does nothing about leaks through the analytics tag |
I can take on the implementation from this article as an engagement
React / Next.js front-end implementation, through to accessibility
1. Why statutory calculations break
Using unemployment benefits as the example, failures fall into roughly four patterns.
- They change every year. The upper and lower limits of the daily amount, and the wage thresholds where the benefit rate changes, are revised every August 1. In 2026, average wages rose about 2.7%, and every band went up (per the MHLW press release).
- Boundaries and rounding are part of the spec. The daily amount is truncated to the yen. An error of 0.0000…1 just before truncation is enough to lose a yen.
- Similar-looking categories move on different axes. The age bands that set the daily-amount cap (under 30 / 30–44 / 45–59 / 60–64) are not the age bands of the benefit-days table (under 30 / 30–34 / 35–44 / 45–59 / 60–64). The boundaries differ.
- Mistakes don't raise errors. Use the wrong band and you get a believable number. No exception, no log line.
The fourth is the nastiest. That is why two mechanisms have to be there from day one: types that stop mix-ups, and tests driven by published figures.
2. Layering: keep the math in functions that know nothing
Taishokun keeps every statutory calculation under src/domain/. The layer's rules are written down in src/domain/CLAUDE.md at the top of that directory. In short:
- No
@/imports. No React, Next.js,fetch, environment variables, orDate.now()/new Date(). The only external package allowed iszod, insimulation-input.ts, which defines the input schema. - Dates arrive as arguments. Functions never ask what day it is.
- No display strings. The layer returns numbers and enum members only. Japanese labels live in the UI dictionary (
src/i18n/ja.ts). - No exceptions and no Result type. "Does not apply" is
null; "not eligible" is a discriminated union carrying the reason; out-of-range values are clamped to the statutory floor or cap.
The employment insurance part alone, src/domain/employment-insurance/, has ten calculation modules plus a shared tables.ts and types.ts, and each module has a same-named *.test.ts beside it (confirmed with ls src/domain/employment-insurance).
src/domain/
├── simulate.ts # composition root: input → every result
├── simulation-input.ts # input schema (Zod); types derived with z.infer
├── shared/date.ts # UTC ISO date arithmetic, nothing else
└── employment-insurance/
├── tables.ts # values that change yearly (the only file you rewrite)
├── types.ts # band and result types
├── basic-allowance.ts # basic allowance daily amount
├── benefit-days.ts # prescribed benefit days
├── restriction.ts # benefit restriction
├── timeline.ts # expands waiting period, restriction, 28-day cycles into dates
├── reemployment.ts # re-employment allowance
└── … (eligibility / claim-period / cashflow / elderly-lump-sum, etc.)
This shape lets you run published worked examples through the engine without rendering anything. The verification tests in section 7 are fast and deterministic precisely because this layer has no clock and no network. The employment insurance domain plus the verification tests (11 files, 298 tests) passed locally in 2.24 seconds (npx vitest run src/content/verification.test.ts src/domain/employment-insurance, run on 2026-09-25).
3. The daily amount: rewrite the official formula as an integer ratio
3.1 The spec: one formula per age band
The basic allowance daily amount is the wage daily amount — derived from the six months of wages before leaving — multiplied by a benefit rate. The lower the wage, the higher the rate, tapering from 80% down to 50% (45% for ages 60–64). The bands from August 1, 2026 are as follows (MHLW, "基本手当日額の計算式及び金額(令和8年8月1日~)").
| Wage daily amount w (example: ages 30–44) | Daily amount y |
|---|---|
| 3,203 yen to under 5,480 yen | y = 0.8w |
| 5,480 yen to 13,490 yen | y = 0.8w − 0.3{(w − 5480)/(13490 − 5480)}w |
| Over 13,490 yen up to 16,540 yen | y = 0.5w |
| Over 16,540 yen | y = 8,270 |
The caps by age band, effective August 1, 2026 (same press release):
| Age at separation | Wage daily cap | Daily amount cap |
|---|---|---|
| Under 30 | 14,900 yen | 7,450 yen |
| 30 to under 45 | 16,540 yen | 8,270 yen |
| 45 to under 60 | 18,220 yen | 9,110 yen |
| 60 to under 65 | 17,400 yen | 7,830 yen |
The floor is 2,562 yen for all ages. The press release explains it as the minimum-wage daily amount (the national weighted average regional minimum wage of 1,121 yen × 20 ÷ 7) multiplied by the 80% rate.
3.2 Implementation: clear the denominators, divide once at the end
Taishokun writes the official formula like this (excerpt from src/domain/employment-insurance/basic-allowance.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);
// For ages 60–64 only, the notice takes the min with a second formula.
const byFloorLine = wage / 20 + (2 * tier2) / 5;
return Math.min(byTaper, byFloorLine);
}
return (wage * (8 * span - 3 * over)) / (10 * span);
}
Two things matter here.
First, the formula is rewritten as an integer ratio. 0.8w becomes w * 4 / 5 and 0.45w becomes w * 9 / 20: multiply in integers first, divide exactly once at the end. Section 4 shows why, with numbers.
Second, the min for ages 60–64. Read the MHLW formula sheet carefully ("基本手当日額の計算式及び金額", reference 1, item 3) and the taper band for people aged 60 to under 65 has a second formula, y = 0.05w + (12120 × 0.4), with the instruction to take "whichever is lower". The code's wage / 20 + (2 * tier2) / 5 is that line.
It is easy to forget, and boundary tests won't catch it: the two lines meet exactly at the end of the taper, a wage daily amount of 12,120 yen, where both give 5,454 yen. They diverge inside the band. At 10,000 yen, the taper alone gives 5,617 yen while the min gives 5,348 yen — an overpayment of 269 yen (substitute into the two formulas above to check). Taishokun therefore has a dedicated regression test, separate from the boundary cases, asserting that the lower formula wins at the point where the two lines cross (a wage daily amount of 7,589 yen).
The order of operations follows the notice exactly.
export function basicAllowanceFor(
wageDailyRaw: number,
band: WageCapAgeBand,
rateBand: BenefitRateBand,
): BasicAllowanceResult {
const caps = WAGE_CAPS[band];
const isAge60to64 = rateBand === 'age60to64';
// 1. Clamp the wage daily amount to the floor and cap
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. Apply the benefit rate
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. Truncate below one yen
const dailyAmount = Math.floor(exact);
return { wageDaily, wageDailyRaw, capApplied, dailyAmount, rate: dailyAmount / wageDaily };
}
The result also carries the unclamped wageDailyRaw and capApplied, which says which end was clamped. The UI needs these to explain "the cap was applied". When the function that made the decision returns its evidence too, the UI never has to re-implement the check.
4. Does floating point really cost a yen? Measured
"Don't use floats for money" is common advice. I measured how often it actually bites.
4.1 The re-employment allowance example is off by one yen if written naively
Hello Work's re-employment allowance guide (LL080801保02) prints the worked example 4,000 yen × 90 days × 70% = 252,000 yen. Here it is, written directly in JavaScript:
node -e "console.log(4000*90*0.7, Math.floor(4000*90*0.7))"
# 251999.99999999997 251999
0.7 has no exact binary floating-point representation (an ECMAScript Number is an IEEE 754 double), so the product comes out slightly low and truncation loses a yen against the official figure. Taishokun's reemployment.ts keeps the rate as an integer percentage (0 | 60 | 70) and divides once at the end.
// Multiply by 70/100, not 0.7.
return {
rate: reemploymentRate(remainingDays, prescribedDays),
cappedDailyAmount,
amount: percent === 0 ? 0 : Math.floor((cappedDailyAmount * remainingDays * percent) / 100),
};
I also counted how often it happens. Across every combination of daily amount 2,562–6,745 yen (up to the re-employment allowance's own cap) × remaining days 1–330 × rate 60%/70% — 2,761,440 combinations — Math.floor(d * r * 0.7) and Math.floor(d * r * 70 / 100) disagreed on 72,019 (2.61%).
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%
More than one in fifty combinations is off by a yen. That is not a rate you can leave to luck.
4.2 The daily-amount formula showed no difference in this sweep
To be fair, I ran the same brute force on the daily-amount taper formula (under 60) written in plain floating point. Across every integer wage daily amount from 5,480 to 13,490 yen, and fractional wage daily amounts from monthly wages divided by 30 (every yen from 164,400 to 404,700 yen per month), not a single input differed from the integer-ratio form. The ages 60–64 formula, min included, behaved the same over the integer wage daily amounts from 5,480 to 12,120 yen that I tried.
Taishokun's own code comment actually says that evaluating the formula in floating point "drops 6745 to 6744.999… at boundaries like w = w2". With the ways of writing the formula I tried, that did not reproduce. Floating-point error appears and disappears depending on how an expression is written and the order of operations, so "this form was fine" stops being true after a small rewrite. If every money calculation uses integer ratios, you never have to re-check each formula for this class of bug. That is easier to keep as a rule, and it is the real reason to choose integer ratios (4.1 shows a case that does go wrong).
| Form | Re-employment 4000×90×70% | Risk |
|---|---|---|
4000 * 90 * 0.7 | 251,999 (one yen off) | High: wrong for 2.61% of combinations |
4000 * 90 * (70 / 100) | 251,999 | High: 70 / 100 is already the same value as 0.7 |
(4000 * 90 * 70) / 100 | 252,000 | Low: integer products below 2^53 are exact |
| A decimal library | 252,000 | Low, but adds a dependency and bundle size |
Taishokun uses no decimal library. Yen products are nowhere near Number.MAX_SAFE_INTEGER (about 9 quadrillion), so integer ratios are enough.
5. Values that change yearly: tables.ts and date-branched reforms
5.1 Amount revisions overwrite a single table
Everything revised on August 1 sits in tables.ts, apart from the logic (excerpt from src/domain/employment-insurance/tables.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 },
};
(The comments are in Japanese because the codebase is; they cite the MHLW press release and the date the source was accessed.) Three rules:
- Write the source and access date next to the constant. When a value is questioned, the comment leads straight to the primary document.
- Don't add year-suffixed twins. No
WAGE_CAPS_2027; rewrite the same constant. Git holds the history. - Show
SCHEDULEon screen. Tell users "this estimate uses the amounts in force from 2026-08-01". If the next revision date passes and the table hasn't been updated, a user can notice.
5.2 Compared with keeping every year's table
The right answer depends on the requirement, so here is the comparison.
| Aspect | Overwrite one table (Taishokun) | Effective-dated tables per year |
|---|---|---|
| Suited to | A consumer tool estimating "what if I quit now" | Business systems and audits that recompute past separations |
| Lookup | Read the constant | A function that finds the latest row with validFrom <= reference date |
| Tests | Current published figures only | Published figures for every year (old documents can disappear) |
| How it fails | A missed revision leaves old amounts (the nextRevision display surfaces it) | A wrong reference date silently picks another year's amounts |
A business system will often need the latter. Section 2's rule — take the reference date as a function argument — carries straight over. If the function calls new Date() internally, the year it looks up becomes whatever time the code happens to run.
5.3 Reforms that change the method branch on a date
Separate from amount revisions are reforms that change how the calculation works, and those must not be overwritten. For example, the benefit restriction for voluntary resignation was shortened from a standard two months to one month for separations on or after April 1, 2025. People who left before then still get two months.
/** 給付制限が「原則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' };
Dates are compared as YYYY-MM-DD strings. With fixed-width ISO strings, lexical order is chronological order, and since no Date object is involved, time zones never get a chance to interfere.
5.4 Derive what can be derived from the tables
The list of possible benefit-day values (90, 120, 150, …, 360 days) is computed from the three benefit-days tables rather than written out.
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);
Copy the list by hand, and the day you fix only the table, the list stays stale. Nothing breaks; the page just keeps printing old day counts. Anything derivable from a table you rewrite every year should be derived in code. In statutory calculations, DRY is a correctness issue.
6. Types: let the compiler stop category mix-ups
6.1 Similar age bands get different types
The two sets of age bands from section 1 are separate types (types.ts).
/** 所定給付日数表の年齢区分。基本手当日額の上限区分とは境界が異なるので混同しない。 */
export type BenefitDaysAgeBand = 'lt30' | 'a30to34' | 'a35to44' | 'a45to59' | 'a60to64';
/** 賃金日額・基本手当日額の上限区分。所定給付日数の年齢区分とは別物。 */
export type WageCapAgeBand = 'lt30' | 'a30to44' | 'a45to59' | 'a60to64';
/** 給付率の区分。賃金日額の上限区分とは別の軸で動く。 */
export type BenefitRateBand = 'general' | 'age60to64';
Pass a BenefitDaysAgeBand as the band in WAGE_CAPS[band] and 'a30to34' doesn't exist, so it fails to compile.
BenefitRateBand is split from the cap band because of the lump-sum benefit for job seekers aged 65 and over. Article 37-4(2) of the Employment Insurance Act sets its wage cap by pointing at Article 17(4)(ii)(d) — the under-30 band. Meanwhile the rate substitution in Article 16(2) applies only to people "aged 60 or over and under 65". So for 65-and-over, the correct combination is "under-30 cap × general rate", which matches none of the basic allowance's age bands. A function that takes a single age can't express that, so basicAllowanceFor(wage, band, rateBand) takes the two decisions as separate arguments.
6.2 One array defines the categories; the type is derived from it
Reasons for separation are defined by a single array.
export const SEPARATION_REASONS = [
'voluntary', // ordinary separation (voluntary without just cause, etc.)
'company', // qualified recipient (bankruptcy, dismissal, etc.)
'specificReason', // specific-reason separation: fixed-term contract not renewed
'specificReasonJustCause', // specific-reason separation: voluntary with just cause
'grossMisconduct', // dismissal for serious misconduct
] as const;
export type SeparationReason = (typeof SEPARATION_REASONS)[number];
// simulation-input.ts
const separationReasonSchema = z.enum(SEPARATION_REASONS);
The Zod input schema, share-URL restoration, and the form options all read this one array. A code comment records that the same list used to be hand-written in three places, and missing one still type-checked.
The specific-reason category is split in two because the preferential benefit-days measure covers only the fixed-term-contract case (Supplementary Provisions Article 18 of the Enforcement Regulations). Merge them, and people who quit voluntarily with just cause are shown more benefit days than the general table allows.
6.3 null is not 0
/** 受給資格の判定結果。満たさない場合は理由を返す(黙って0円にしない)。 */
export type EligibilityResult =
| { readonly eligible: true }
| {
readonly eligible: false;
readonly reason: 'insufficientInsuredMonths' | 'ageOutOfRange' | 'benefitDaysUnavailable';
};
The benefit-days table has combinations that cannot logically occur, such as under 30 with 20+ years insured. Those cells are null, not 0 — "0 days" reads as a different claim, "you get zero days". Types distinguish "not applicable", and the UI picks the explanation from its dictionary.
7. Turning published worked examples into test fixtures
7.1 The verification page and the tests read the same array
Taishokun has a calculation verification page (in Japanese) that lists worked examples published by public bodies next to the app's results. Its data source, src/content/verification.ts, doubles as test input.
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 },
// …
],
},
// re-employment allowance, retirement-pay tax, lump sum for 65+, sickness allowance, social insurance
];
The test (src/content/verification.test.ts) maps each case ID to a real calculation and runs every case through the engine.
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));
// Check the reverse too: catch calculations left behind after a case leaves the page.
expect(Object.keys(CALCULATORS).sort()).toEqual([...caseIds].sort());
});
});
With this in place, a verification result that hasn't passed a test cannot appear on the page. Add a case to the page without a calculation and the test fails; leave a calculation behind after removing its case and the last test fails. The page that claims reliability carries its own evidence as structure.
There are 30 cases (grep -cE "^ id: '" src/content/verification.ts returns 30; the live /verification page also shows 30).
| Group | Cases | Source |
|---|---|---|
| Basic allowance daily amount | 10 | MHLW press release attachment, recipient leaflet |
| Re-employment allowance | 3 | Hello Work re-employment allowance guide |
| Tax on retirement pay | 7 | National Tax Agency No. 2732, Yokohama City resident-tax examples |
| Lump sum for job seekers 65+ | 5 | Employment Insurance Act Art. 37-4, operations manual |
| Sickness allowance daily amount | 3 | Japan Health Insurance Association |
| Post-retirement social insurance | 2 | Japan Health Insurance Association |
7.2 Published examples aren't enough: add boundaries
The MHLW press release prints only two examples, wage daily amounts of 6,000 and 9,000 yen. Those only exercise the middle of the taper. So the tests add the thresholds where the rate changes (5,479 / 5,480 / 13,490 yen, and 12,120 yen for ages 60–64) plus the caps and floor, laid out with it.each. Every boundary value is a figure printed in the leaflet or in the attachment's charts.
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 Never compute the expected value with the implementation's formula
src/domain/CLAUDE.md limits where expected values may come from to two places:
- Copied verbatim from an official worked example (with the publication and case number cited)
- Derived by hand, independently of the implementation
Tests that recompute the expected value with the implementation's own algorithm are banned, because they pass with the bug baked in. Dates work the same way: the payout-schedule tests use hand-counted date strings for the end of the waiting period and the benefit start date.
it('待期は受給資格決定日を含めて7日間なので満了日は決定日+6日', () => {
expect(timeline().waitingPeriodEnd).toBe('2026-09-07'); // determination date 2026-09-01
});
it('給付制限1か月は待期満了日から起算する', () => {
const result = timeline();
expect(result.restrictionEnd).toBe('2026-10-07');
expect(result.benefitStart).toBe('2026-10-08');
});
(The first test reads: "the waiting period is 7 days including the determination date, so it ends on determination date + 6". The second: "a one-month restriction counts from the end of the waiting period".)
Coverage thresholds are statements 100 / functions 100 / lines 100 / branches 95 (thresholds in vitest.config.mts). For statutory math, though, what really matters is not the coverage number but where the expected values came from. 100% coverage guarantees nothing if the expectations were generated by the implementation.
7.4 Dates: compute only with UTC ISO strings
The payout schedule expands the 7-day waiting period, the benefit restriction, and the 28-day unemployment certification cycle into real dates. All date arithmetic goes through UTC helpers in shared/date.ts, so a statutory date in Japan never shifts by a day because of the user's device time zone.
Adding months has a trap specific to legal calculations: one month after January 31 is not February 31. addMonths clamps to the last day of the target month when the same day doesn't exist. And when benefit days are cut off at the end of the benefit period, the cut is made on the day the benefit covers, not the transfer date (Article 20(1) of the Act). Cutting on transfer dates would drop payments for in-period days that happen to be transferred after the deadline, understating the total.
8. Privacy: keep input inside the browser
People enter their age, monthly salary, separation date, and reason for leaving — none of which they want anyone else to see. Taishokun does every calculation in the browser and sends nothing to its server. Every route is statically generated except POST /api/contact for the contact form (among the route handlers under src/app, llms.txt and feed.xml are force-static; only api/contact is dynamic).
Not sending input to your own server isn't the whole promise, though. The analytics tag can carry it out, for example through URLs.
8.1 URLs: put input in the fragment and strip it before analytics
Shareable URLs carry the input in the fragment (after #). RFC 3986 defines the fragment as interpreted on the client side, so it isn't sent to the server in an HTTP request. But the page_location sent to GA4 is just a string: pass location.href as-is and the fragment goes along with it. A code comment records that the share URL did leak through exactly this route. So URLs are stripped before they reach analytics (src/lib/analytics/tracked-url.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();
// Don't rebuild the fragment. That is where the input lived.
return `${parsed.origin}${parsed.pathname}${query === '' ? '' : `?${query}`}`;
}
Query parameters go through an allowlist. With a denylist, whatever state someone puts in the URL next would leak by default; with an allowlist it stays in by default.
8.2 Events: remove the place where raw values could go
The analytics event parameter type (AnalyticsEventParams) has no field for age, salary, separation date, or benefit amount. Wanting to add one is a compile error — that is the point.
There is one exception worth stating plainly. Taishokun does send input rounded into coarse bands as analytics user properties: age by the benefit-days table's bands, monthly salary in 100,000-yen steps, total benefit by order of magnitude. toInputProfile in src/lib/analytics/input-profile.ts does the conversion, and its return type is built only from closed vocabularies (unions of string literals), so raw values can't be mixed in. The "hard-to-employ" status, which is sensitive personal information under Japanese law, is excluded from that return value and sent through a separate path only with explicit consent. The bands are listed in Taishokun's privacy policy.
Writing "we send nothing" would be easier. Not writing something untrue, closing the scope of what is sent with types, and publishing that scope makes the system auditable and easy to explain later.
8.3 CSP and Zod: set jitless first
Taishokun's production CSP does not allow 'unsafe-eval' (src/lib/security/csp.ts; it is allowed only in development). Zod 4 probes whether new Function("") works so it can compile fast validators. Under a CSP that forbids eval, the probe throws; Zod swallows the error and falls back, but the browser still records a CSP violation (the comment on allowsEval in Zod's v4/core/util.ts describes exactly this).
import { z } from 'zod';
// Disable Zod's JIT so a no-eval CSP records no violation.
z.config({ jitless: true });
The result of allowsEval is cached, so the setting only works if it runs before the first parse. Taishokun's rule is to call it at the top of every module that uses Zod (simulation-input.ts and the contact form schema).
9. Pitfall checklist
What to look for when writing or reviewing this kind of code.
| Check | What goes wrong if you miss it |
|---|---|
Rates multiplied as integer ratios (no * 0.7) | One yen off the official figure (2.61% of re-employment combinations) |
The min with the second formula in the 60–64 taper band | Overpayment for ages 60–64 (269 yen at a wage daily amount of 10,000 yen); boundary tests won't catch it |
| Cap age bands and benefit-days age bands as separate types | The 30–34 vs 35–44 distinction disappears |
| Re-employment allowance cap (6,745 / 5,454 yen) kept apart from the basic allowance cap | Re-employment allowance overstated |
| Source, effective date, and next revision date on every table value | A missed revision goes unnoticed |
| Method-changing reforms branched on a date rather than overwritten | Calculations break for people who left before the reform |
"Not applicable" returned as null or a discriminated union, not 0 | "Zero yen" and "not applicable" become indistinguishable |
| Expected values from published figures or hand calculation | Expectations derived from the implementation catch nothing |
No new Date() inside calculation functions | Tests change with the run date; year lookups drift |
| Fragment and extra query parameters stripped from URLs sent to analytics | Input reaches the analytics provider |
10. The app this code runs in
The code in this article runs in Taishokun, a free Japanese unemployment benefit calculator (『たいしょくん』) that estimates unemployment benefits, tax on retirement pay, and post-retirement social insurance together. From the daily amount and benefit days, it builds a month-by-month payout schedule reflecting the 7-day waiting period and the benefit restriction, plus the re-employment allowance, retirement-pay tax, and National Health Insurance and resident tax after leaving — all without the input leaving the browser. The site is Japanese only. The design decisions behind it as a solo product are in the Taishokun write-up in Labs.
Once more: neither the tool's results nor this article guarantee anyone's benefit amount. Check your own eligibility and amount with your local Hello Work office.
For related reading, the broader discipline of validating types at boundaries is in the TypeScript type-safety discipline guide, Zod usage is in the practical Zod guide, and building a CSP is in the Next.js security headers and CSP guide.
I also take on development work that builds legally defined calculations — payroll, tax, social insurance, benefits — into business systems with the same design: a pure-function layer, sourced tables, tests from published examples, and analytics that keep input inside the browser. See Services for how engagements work.