はじめに
金融系のデモアプリで、金利・料率の単位ミスが何度も出ていました。配当利回りは年利 %、手数料は bps、計算は小数、と画面ごとに表記が違って、集計画面で配当金が 100 倍ずれて表示される事故が頻発しました。
単位の取り違えは、人間がレビューで指摘しても、AI にコードを書かせても完全には防げなかったです。
「次は気をつけよう」は、一番効かない再発防止策でした。
そこで、TypeScript の強力な武器である Brand 型(名札付きの型) を導入することにしました。これで数値の混同はコンパイルエラーで止まる……はずでした。
本記事では、なぜ型を入れたのに防げなかったのか、そして最終的にどうやって「構造」で再発を防止したのかを振り返ります。
先に: Brand 型はどこで効くのか
Brand 型(number & { __brand: 'hoge' })は、関数の引数や戻り値の型に置いたときにだけ効きます。意図していないところで算出された素の number を渡せない関所とすることが、唯一の役割です。
ですから Brand を活かすには
- 値を作る経路を Parser 層に集約する
- 画面への表示に使用する場合は、文字列で返して計算経路に戻さない
- Lint で抜け道を塞ぐ
の 3 つと組み合わせる必要があります。
何が起きていたか:同じ「0.5%」に 3 つの顔がある
最大の問題は、同じ「0.5%」という値が、プログラムの中では全く別の数字として扱われていたことです。
| 表記 | 例(0.5%の時) | 使われる場所 |
|---|---|---|
| 小数 | 0.005 | 計算ロジック(利息の計算など) |
| パーセント | 0.5 | 画面の入力欄、表示 |
| basis points | 50 | 手数料の設定 |
「0.5」が届いたとき、それは 0.5% なのか 50% なのか?
システムの中では、これらの数値がバケツリレーのように受け渡されます。
画面担当:「ユーザーが 0.5 って入力したよ!送るね」
計算担当:「おっ、0.5 が届いたな。これは 小数(50%) のことだな?」
結果: 0.5% のはずが 50% で計算され、配当金が 100 倍になる
このように、データの入り口と出口で「これはどの単位か?」という解釈がバラバラだったため、レビューで気をつけていても「100 倍のズレ」が何度も再発してしまったのです。
1 回目:すべての値を「小数」に統一して大失敗
まずは「プログラムの中ではすべて小数(0.005 など)で扱い、画面に出す時だけパーセントに戻せばいい」というシンプルな共通ルールを作りました。しかし、これでは不十分だとすぐに気づきます。
理由は、小数にすると「その値が何なのか」という情報が消えてしまうからです。
-
「期間」がわからない: 「年利 0.5%」も「日利 0.5%」も、小数にすると同じ
0.005です。これらが混ざったときに、プログラムは区別がつきません。 - 「計算ルール」がわからない: 金融の世界には、1年を 365日とするか 360日とするかといった細かいルール(計算基準)があります。単なる数値だけでは、後段の計算でどのルールを適用すべきか判断できません。
結局、ただの数値ではなく { value: 小数, period: '年利' } のような、「数値 + 意味(タグ)」をセットにしたデータ構造にする必要がありました。
2 回目:期間ごとにBrand型を設定したら複雑すぎた
次に、「年利」や「日利」ごとに TypeScript の Brand型(名札付きの number) を作って、型で厳密に管理しようとしました。
type AnnualRate = number & { __brand: 'rate_annual' } // 年利専用の数値
type DailyRate = number & { __brand: 'rate_daily' } // 日利専用の数値
これで「年利を期待する関数に、間違えて日利を渡す」といったミスはコンパイルエラーで防げるようになりました。しかし、ここで致命的な問題にぶつかります。
計算すると「名札」が剥がれてしまう
TypeScript の仕様上、計算(足し算や引き算)をすると、せっかく付けた名札が剥がれて普通の number に戻ってしまうのです。
const a = 0.01 as AnnualRate;
const b = 0.02 as AnnualRate;
const c = a + b; // 結果の c は、ただの number に戻ってしまう!
名札を維持したまま計算するには、計算のたびに名札を付け直す「専用の計算関数(ラッパー)」を自作しなければなりません。
function addAnnual(a: AnnualRate, b: AnnualRate): AnnualRate {
return (a + b) as AnnualRate; // 計算のたびに型を上書きして名札を付け直す
}
「年利の足し算」「年利の掛け算」「日利の足し算」……と、期間 × 演算の種類だけ関数を作る必要が出てきます。 レビューで「電卓アプリでも作ってるの?」と呆れられるレベルの作業量です。
「全員がルールを守る」のは無理だった
このやり方の最大の弱点は、誰か一人が a + b と直接書いただけで、型による保護が崩壊することです。
特に最近は AI がコードを書くことも多く、AI は文脈に合わせて「普通に足し算するコード」を生成しがちです。人間がすべてのファイルを一言一句チェックし続けるのは、再発防止策として現実的ではありませんでした。
ここで方針を転換します。
「型を細かく分けてコンパイル時にすべて解決する」のは諦め、「値を作る場所を 1 箇所に絞り、変な書き方をしたら Lint(自動チェックツール)で即座に弾く」という、より強固な構造を目指すことにしました。
3 回目: Brand を Parser と Lint で囲む
核は Parser 層の単一化。これが「素の値を Rate に変える唯一の経路」を作ります。
Rate を 1 本にして、Parser だけが作れる状態にする
declare const rateBrand: unique symbol
export type Rate = {
readonly [rateBrand]: never
readonly period: 'annual' | 'monthly' | 'daily'
readonly value: number
}
// 手数料 API(bps)→ Rate
export function parseRateFromBps(bps: number, period: Period): Rate {
return { period, value: bps / 10000 } as Rate
}
Parser 層は 外部の値を内部型に変換する関数を集めた層(一般用語の構文解析の parser とは別物)です。as Rate を書くのもこの層の中だけ。これで Rate は 必ず Parser を通った値 だと言える状態になります。
表示は文字列で返す
表示用の関数は文字列を返して、表示の値が計算経路に逆流しない壁を作ります。
const a = formatRateAsPercent(annualRate) // string
const b = formatRateAsPercent(dailyRate) // string
a + b // OK: 文字列連結 "0.5%0.5%" → 画面で見た目に気づく
a - b // 型エラー(* / も同じ)
compoundDividend(principal, a, years) // 年単位。型エラー: Rate 期待引数に string を渡せない
number で返すと 4 つの演算とも気づかずに通ります。
ただしこれは完全な防壁ではありません。parseFloat で数値に戻す書き方は型では止まらないので、計算経路への流入を型で塞ぐ仕掛け くらいの位置付けです。
Lint で 2 本塞ぐ
残念ながら、AI は型エラーを消すのが得意なので、型エラーが発生してもas Rate で堂々と踏み越えてきます。コードベース全体の暗黙の規約は、型では伝わらないようです。
Parser 外で、以下の 2 つを ESLint で禁止します。
// エラー例
const rate = response.rate_decimal // 生フィールドの直接アクセス
const fake = { period: 'annual', value: 0.005 } as Rate // Parser 外の as Rate
no-restricted-syntax で TSAsExpression[typeAnnotation.typeName.name='Rate'] のようなセレクタを書けば as Rate を狙い撃ちにできます(@typescript-eslint/consistent-type-assertions も近縁ルール)
エラーになれば、AIくんも流石に理解を示してくれます。ルールを無視する悪い子はいなくなりました。
まとめ
- Brand 型は引数や戻り値の型に置いたときにだけ効く。「Parser 経由を強制する関所」として使う、その 1 機能の道具と思っておく
- 値を作る経路を Parser 層に集約するのが核。Brand と文字列終端と Lint はその支え
Brand 型は「これさえ入れれば単位の取り違えは防げる」道具に見えますが、実際は Parser 層に値の生成経路を集約し、表示を文字列で終端し、Lint で抜け道を塞ぐ、という構造と組み合わせて初めて意味を持ちました。同じ遠回りをする人を 1 人でも減らせたら嬉しいです。
Brand 型のより良い活用方法がありましたら、共有していただけると助かります!