ある関数がタイムアウトをミリ秒で要求する。そこに 5000 を渡す。後で別の関数がタイムアウトを秒で要求する。そこに 5 を渡す。その間のどこかで setTimeout(duration, callback) を呼び出すと、1時間23分間何も起こらない。
TypeScript はここでは助けにならない。5000 と 5 のどちらも number だ。コンパイラはメートルとフィートの距離、摂氏と華氏の温度、タイムスタンプと継続時間の違いを見分けられない。テストスイートもおそらくこれを捕まえない。なぜなら計算自体は正しいからだ。間違っているのは単位だけだ。
解決策は、単位をドキュメントとして扱うのをやめ、型として扱うことだ。
なぜ number は物理量にとって間違った型なのか
TypeScript は構造的型付けを採用している。2つのオブジェクトは形状が一致すれば互換性がある。これは通常メリットだが、number のようなプリミティブに対しては、すべての数値が互換可能になるという意味だ。number は number は number だ。
実行時チェックでも単位のミスは検出できるが、メンテナンスコストが高く、省略されやすい。すべての関数引数、すべての API レスポンス、別ファイルで定義されたすべての定数を検証する必要がある。実際には誰もここまでやらない。チェックはコメントになり、コメントは嘘をつく。
代わりに、単位を型に直接エンコードする。コンパイル時に Seconds と Milliseconds は互換性のない型になる。Meters に Meters を掛けると SquareMeters が得られる。Miles に Kilometers を足すとコンパイラが拒否する。実行時には値は相変わらずただの数値だ。ラッパーオブジェクトも実行時検証もパフォーマンスコストもない。これがゼロコスト抽象化だ。
ファントム型が数値をブランド付き単位に変える仕組み
TypeScript はプリミティブの公称型付けをサポートしていないが、交差型と一意のシンボルはサポートしている。基盤となる値が同一でも、異なるブランド同士が互換性を持たないようにプリミティブにブランドを付与できる。
パターンはこうだ:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
__brand プロパティは実行時には存在しない。これはファントム型だ。型システムにのみ存在する。だがそれだけで Meters と Kilometers を相互に非互換にできる。
プレーンな number をブランド付き型に誤って代入することはできない。これがポイントだ。明示的に構築する必要があり、その際に単位を宣言しなければならない。
TypeScript で動作する単位システム
以下は構築、変換、算術を扱う最小限かつ完全な実装だ。
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
使用例:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// これはコンパイルされる。
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// これはコンパイルされない。
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
エラーはバグが導入された場所に現れる。値が最終的に使われる場所ではない。誰かが秒のパラメータにミリ秒を渡したために velocity を3ファイル遡って調査する必要はない。
基本単位から派生単位を導出する
このパターンは派生単位にもスケールする。MetersPerSecond を手書きする代わりに、汎用的なコンストラクタを使って基本型から導出できる。
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
実際には、完全な次元解析までは必要ない場合が多い。ほとんどのチームは十数個の単位型を超えると収益逓減に陥る。目標は物理学をモデル化することではない。目標は最も高価なカテゴリーのバグ、すなわち計算は正しいが単位が間違っているというバグを排除することだ。
知っておくべきトレードオフ
ブランド付き型は無料ではない。人間工学のコストがかかる。
すべてのリテラルをコンストラクタでラップしなければならない。setTimeout(callback, 5000) は setTimeout(callback, milliseconds(5000)) になる。入力が増える。チームがコンストラクタの使用に一貫性を欠くと、コードベース全体に安全でないキャストが散らばることになる。このパターンが機能するのは、全員が使う場合だけだ。
型推論も煩雑になる。配列メソッドやジェネリック関数がエラーメッセージにブランドを露出させることがある。プレーンな number[] は (number & { readonly __brand: "Milliseconds" })[] より読みやすい。シグネチャを読みやすく保つために型エイリアスが必要になることもある。
シリアライゼーションも摩擦ポイントだ。JSON はブランド付き型の概念を持たない。Meters の値をネットワーク経由で送信すると、受信側ではプレーンな number として到着する。境界でブランドを再構築する必要がある。これを行うべき正しい場所ではあるが、追加のコードは必要だ。
最大の制限は、これが TypeScript 専用のテクニックであることだ。システムに Python のサービス、Go のマイクロサービス、あるいはプレーンな JavaScript のコンシューマが含まれる場合、ブランドは言語境界で消滅する。システムの境界では依然として実行時検証が必要だ。ブランド付き型は TypeScript の内部コードを保護する。外部データに対するスキーマの代替にはならない。
チームを煩わせずに導入する方法
コードベースのすべての数値にブランドを付与するな。実際のインシデントを引き起こしたパラメータから始めろ。
- 本番環境で直近3件の単位関連バグを特定する。ミリ秒対秒、異なる通貨単位、緯度対経度、画面座標対ドキュメント座標などを探す。
- それらの特定の型にブランドを付与する。コンストラクタと変換関数を追加する。
- それらの値が使われる関数を更新する。コンパイラに導いてもらう。
- それらのパラメータに生の
numberを禁止する lint ルールを追加する。
ループカウンタ、配列インデックス、パーセンテージにブランドを付与するな。これらは無次元だ。そこにブランドを追加しても儀式に過ぎず価値はない。
公称型付けが強力な言語で作業している場合、より良い選択肢がある。Rust ユーザーは uom クレートを確認すべきだ。F# と OCaml にはコンパイラに組み込まれた units of measure がある。TypeScript の構造型システムは、これをファーストクラスの機能ではなく回避策にする。だがこの回避策は、実際のバグを捕まえるには十分に優れている。
FAQ
実行時オーバーヘッドは増えるか?
いいえ。ブランドはコンパイル時専用の構造だ。コンパイル後、meters(100) はただの数値 100 だ。ラッパーオブジェクトも追加プロパティも実行時チェックもない。
乗算と除算はどうなるか?
明示的な関数またはオーバーロードされた演算子が必要だ。TypeScript は演算子オーバーロードをサポートしていないため、distance / time は per() または speed() 関数を経由しなければならない。これは冗長だが、バグが検出される理由でもある。
サードパーティライブラリで使えるか?
ライブラリがブランド付き型を受け入れる場合のみ可能だ。setTimeout が number を期待する場合、ブランドは number との交差型なので Milliseconds を渡せる。逆は成り立たない。ライブラリが number を返す場合、単位型として使う前に明示的にブランドを付与する必要がある。
HalfSeconds のような分数単位はどう扱うか?
基本単位とコンストラクタを使う。halfSeconds(1) は Milliseconds(500) を返す。すべての細分にブランドを作成するな。基本単位の数は少なく保つ。
変数名に単位名を書くのをやめろ
変数を timeoutInMs と呼ぶのはドキュメントだ。ドキュメントは陳腐化する。timeout: Milliseconds と呼ぶのは型だ。型は強制される。
次にデバッグしていてタイムアウトが1000倍ずれていたことに気づいたとき、変数名で十分だったか自問せよ。十分ではなかった。単位を型にエンコードし、コンパイラに仕事をさせ、自分にこの関数が秒を求めているのかミリ秒を求めているのか覚えておくことを信頼するのをやめろ。