0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Brand 型を入れたのに、計算バグが止まらなかった話

0
Last updated at Posted at 2026-05-11

はじめに

金融系のデモアプリで、金利・料率の単位ミスが何度も出ていました。配当利回りは年利 %、手数料は 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-syntaxTSAsExpression[typeAnnotation.typeName.name='Rate'] のようなセレクタを書けば as Rate を狙い撃ちにできます(@typescript-eslint/consistent-type-assertions も近縁ルール)

エラーになれば、AIくんも流石に理解を示してくれます。ルールを無視する悪い子はいなくなりました。

まとめ

  • Brand 型は引数や戻り値の型に置いたときにだけ効く。「Parser 経由を強制する関所」として使う、その 1 機能の道具と思っておく
  • 値を作る経路を Parser 層に集約するのが核。Brand と文字列終端と Lint はその支え

Brand 型は「これさえ入れれば単位の取り違えは防げる」道具に見えますが、実際は Parser 層に値の生成経路を集約し、表示を文字列で終端し、Lint で抜け道を塞ぐ、という構造と組み合わせて初めて意味を持ちました。同じ遠回りをする人を 1 人でも減らせたら嬉しいです。

Brand 型のより良い活用方法がありましたら、共有していただけると助かります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?