TypeScriptのBranded Types(ブランド型)とは|実装と型安全の設計指針
Branded Types(ブランド型)は、TypeScriptの構造的部分型では区別できない「同じ基本型だが意味が違う値」——ユーザーIDと注文ID、円とドル、メールアドレスと生の文字列——をコンパイル時に別型として扱わせるパターンです。numberやstringにブランド(目印)を交差させ、取り違えを型エラーとして検出します。この記事では3通りの実装、名義型との違い、asの落とし穴と実行時検証、そして導入すべき場面と避けるべき場面までを一度ずつ整理します。
まとめ:Branded Typesの要点
- 目的:構造的部分型(Structural Typing)で素通りしてしまう「同型・別意味」の値の取り違えを、コンパイル時に止める。
- 実装:基本は
型 & { __brand: "名前" }の交差型。再利用はBrand<T, B>ユーティリティ、堅牢化はunique symbolブランド。 - 実行時:ブランドは型情報のみでランタイムに残らない。外部入力はファクトリ関数か型ガード/スキーマ検証を必ず通す。
- 適所:ID・通貨・トークンなど取り違えが致命的な値に限定。全データへの適用は可読性を落とすので避ける。
Branded Typesとは:構造的部分型の穴を塞ぐ
TypeScriptは既定で構造的部分型を採る。型の「形」が一致すれば名前が違っても代入できるため、次の2つのIDは互換になり、取り違えても型エラーにならない。
type UserId = number;
type OrderId = number;
function cancelOrder(id: OrderId) {}
const userId: UserId = 42;
cancelOrder(userId); // 通ってしまう(どちらも number)
Branded Typesは、基本型に「実体を持たない目印プロパティ」を交差させ、同じnumberでも別型として扱わせる。
type UserId = number & { __brand: "UserId" };
type OrderId = number & { __brand: "OrderId" };
function cancelOrder(id: OrderId) {}
const userId = 42 as UserId;
cancelOrder(userId); // エラー: UserId は OrderId に代入できない
__brandは値として代入するものではなく、型システムに差異を認識させるための幻のプロパティ(phantom property)。実行時のオブジェクトには存在しないため、メモリや速度のオーバーヘッドはない。
Branded Typesの実装3パターン
交差型で直接ブランドを付ける
最短の形。asで明示キャストしてブランド付き値を作る。手軽だが__brandという文字列キーは他のブランドと衝突しうる点に注意(後述のunique symbolで解消できる)。
type Email = string & { __brand: "Email" };
const raw = "[email protected]";
const email = raw as Email;
再利用可能な Brand<T, B> ユーティリティ
ブランド定義を毎回書くと冗長になる。ジェネリック型で共通化すると、識別子ごとの定義が1行で済む。ジェネリクスの型引数でブランド名を差し込む形だ。
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, "UserId">;
type OrderId = Brand<number, "OrderId">;
type Email = Brand<string, "Email">;
unique symbol でブランドキーの衝突を防ぐ
文字列キー__brandは、別のライブラリやコードが同じ{ __brand: string }を宣言すると意図せず互換になりうる。unique symbolをブランドキーにすると、そのシンボルは唯一無二で外部から再現できず、より堅牢に閉じたブランドを作れる。競合記事が説明を省きがちな実装で、取り違え防止を本気で担保したい場合の要点だ。
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<number, "UserId">;
// [brand] は宣言だけの unique symbol なので外部から偽装できない
構造的部分型(Structural)と名義型(Nominal)の違い
型の同一性の判定方式には2系統ある。TypeScriptは前者、JavaやC#、Rustは後者を採る。Branded Typesは、構造的部分型の柔軟さを保ったまま、必要な箇所だけ名義型的な厳密さを持ち込む手法だと言える。
| 観点 | 構造的部分型(TypeScript) | 名義型(Java/C#/Rust) |
|---|---|---|
| 同一性の判定 | 型の形(プロパティ)が一致すれば同型 | 型の名前が違えば別型 |
| 取り違え防止 | 既定では効かない | 言語仕様で効く |
| 柔軟さ | 高い(形が合えば代入可) | 低い(明示変換が必要) |
| Branded Typesの位置づけ | 名義型的な厳密さを部分的に付与 | —(標準で名義型) |
as の落とし穴と実行時バリデーション
Branded Typesの弱点は、生成時にasキャストで「これはUserIdだ」と言い切っている点にある。asは値の中身を検査しないため、不正な値でもコンパイルを通してしまう。外部(API・DB・ユーザー入力)から来る値は、必ず検証を挟んでからブランドを付ける。
ファクトリ関数で生成口を一本化する
ブランド付き値の生成を関数に閉じ込め、そこでだけasを使う。コード全体に散らばる裸のasを排除でき、検証も1箇所に集約できる。
type UserId = Brand<number, "UserId">;
function toUserId(n: number): UserId {
if (!Number.isInteger(n) || n <= 0) {
throw new Error("invalid UserId");
}
return n as UserId;
}
型ガードとスキーマ検証で実行時も守る
ブランドはランタイムに残らないので、実行時の妥当性はブランドとは別に確かめる必要がある。単純な検査は型ガード(is述語)、複雑な構造はzodなどのスキーマ検証ライブラリで行い、検証を通った値にだけブランドを与える設計にする。
function isUserId(v: unknown): v is UserId {
return typeof v === "number" && Number.isInteger(v) && v > 0;
}
導入すべき場面と避けるべき場面
Branded Typesは万能ではない。判断を言い切るなら——取り違えが実害に直結する値にだけ使い、それ以外には使わない。
導入が効く場面:
- 同じ
number/stringで表す複数の識別子(UserId・OrderId・ProductIdなど)が混在する。 - 通貨・単位(JPYとUSD、メートルとフィート)を数値で持ち、換算漏れが会計や計算の事故になる。
- 認可トークンやハッシュ済みパスワードなど、生値と検証済み値を型で区別したい。
避けたほうがよい場面:
- その値がコード内の一箇所でしか使われず、取り違えの経路がそもそも無い。
- 頻繁に生の基本型と往復する処理が多く、キャストの記述コストが安全性の利得を上回る。
- 実行時の妥当性検査で十分に守れており、コンパイル時の区別が過剰になる。
よくある質問
Branded Typesは実行時のパフォーマンスに影響しますか?
影響しません。ブランドは型情報だけで、コンパイル後のJavaScriptには__brandプロパティもキャストも残りません。生成されるオブジェクトは素のnumberやstringのままです。
__brand と unique symbol、どちらを使うべきですか?
小規模で自プロジェクト内に閉じるなら文字列キーの__brandで十分です。ライブラリ配布や、他コードとのブランド衝突を厳密に防ぎたい場合はunique symbolを使い、外部から再現できない閉じたブランドにします。
TypeScriptに名義型(Nominal Typing)の標準機能はありますか?
専用のキーワードはありません。名義型的な区別は、この記事のBranded Types(交差型やunique symbolによるブランド付け)で実現するのが定石です。
as を使わずにブランド付き値を作れますか?
生成のどこかでas相当のキャストは必要になりますが、ファクトリ関数の内部にだけ閉じ込めるのが安全です。関数の外で裸のasを書かない設計にすれば、検証を通った値だけがブランドを得ます。
ブランドを付けた値は元の基本型として使えますか?
使えます。UserIdはnumberとの交差型なので、numberを要求する箇所(算術やログ出力など)にはそのまま渡せます。逆方向(生のnumberをUserIdとして使う)だけがブロックされます。