【実務・中級編】 Branded Types (Nominal Typingの模倣) – TypeScript実践ガイド

TypeScriptの型システム、本当に最高ですよね。開発フィールは軽快だし、リファクタリングは怖くない。でも、実務で中規模から大規模なアプリケーションを任されるようになると、ある「モヤモヤ」に直面しませんか?

そう、「IDの混ぜるな危険」問題です。

例えば、ユーザーIDと記事IDを扱うこんなコード、よく見かけませんか?

type UserId = string;
type ArticleId = string;

function deleteUser(id: UserId) {
// 処理…
}

const currentArticleId: ArticleId = “art_999”;

// あっと驚くヒューマンエラー!記事IDをユーザーIDの関数に渡しちゃったよ!
deleteUser(currentArticleId);

TypeScriptのコンパイラは、これに「おっ、両方とも `string` じゃねえか、よし通したる!」と涼しい顔でパスポートを発行します。結果、本番環境で「なんでこの記事IDのユーザーが消えてるの!?」という悪夢のようなバグを生むわけです。

TypeScriptは「構造的部分型(Structural Subtyping)」を採用しているため、中身が同じプリミティブ型であれば、名前(エイリアス)が違っても同じ型として扱われます。これは柔軟性の裏返しですが、ドメインモデルの厳密さが求められるフロントエンドの現場では、時として牙をむきます。

そこで今日、君たちに伝授したいのが、この構造的部分型の隙を突き、TypeScript上で「名目的型(Nominal Typing)」を模倣する「Branded Types(branded types / ブランテッド型)」という実務直結のテクニックです。

—

1. Branded Typesの仕組みと、ブラウザの裏側のハ話

Branded Typesのアイデアは極めてシンプルです。「型に『焼印(ブランド)』を押して、物理的に混ざらないようにする」。

実装の基本形を見てみましょう。

// ブランド用のダミープロパティ(実行時には存在しない)
declare const __brand: unique symbol;

type Brand = T & { [__brand]: TBrand };

ここでやっていることは、元の型(`T`)に、交差型(`&`)を使って誰も持っていないユニークなシンボルをくっつけているだけです。

「ちょっと待て、ブラウザの裏側(ランタイム)でメモリとかパフォーマンスに悪影響はないのか?」

鋭い質問ですね。結論から言うと、ランタイムのコストは「完全ゼロ」です。
TypeScriptの型システムは、コードがJavaScriptにトランスパイルされる(コンパイルされる)際にすべて消え去ります。上記の `[__brand]` はあくまでコンパイラを欺く(あるいは正しい道に導く)ためのメタデータに過ぎません。JavaScriptのエンジン(V8など)から見れば、ブランドされた型はただの素の `string` や `number` です。余計なプロパティがオブジェクトにくっついてメモリを圧迫するようなことは絶対に起きないので、安心してください。

—

2. 現場ですぐに使える!実践的Branded Typesの実装パターン

では、実務のコードベースにどう組み込むか。コピペしてそのまま使える、洗練されたボイラープレートを共有します。

/

  • 任意のプリミティブ型に「ブランド」を付与するためのヘルパー型

/
declare const __brand: unique symbol;
type Branded = T & { readonly [__brand]: TBrand };

// — 実際のドメインモデル定義 —
export type UserId = Branded;
export type ArticleId = Branded;
export type Yen = Branded;

// — キャスト(生成)用のユーティリティ関数 —
// 外部からの生データ(APIレスポンス等)を安全にブランド型に変換するファクトリー関数
const createUserId = (id: string): UserId => id as UserId;
const createArticleId = (id: string): ArticleId => id as ArticleId;
const createYen = (amount: number): Yen => {
if (amount < 0) throw new Error("金額にマイナスは指定できません"); return amount as Yen; }; // ========================================== // 実戦での使用例 // ========================================== function fetchUserProfile(userId: UserId) { console.log(`Fetching user: ${userId}`); } function publishArticle(articleId: ArticleId) { console.log(`Publishing article: ${articleId}`); } // 1. 正しい使い方は当然コンパイル通ります const validUserId = createUserId("usr_12345"); const validArticleId = createArticleId("art_99999"); fetchUserProfile(validUserId); // OK! publishArticle(validArticleId); // OK! // 2. うっかり間違えたら...? // fetchUserProfile(validArticleId); // ❌ コンパイルエラー: // 'ArticleId' 型の引数を 'UserId' 型のパラメータに割り当てることはできません。 // プロパティ '[__brand]' の型が互換性がありません。 // 3. 生のプリミティブをそのまま突っ込むのも防げる // fetchUserProfile("usr_12345"); // ❌ コンパイルエラー: string を UserId に直接代入は許さない! このパターンの美しいところは、APIから返ってきたデータをコンポーネントに渡す境界線(APIクライアントやZodなどのバリデーション層)で一度キャスト・検証してしまえば、あとはアプリケーション全体で絶対にIDの取り違えが起きなくなるという点です。

—

3. シニアが教える、現場で導入する際の注意点とベストプラクティス

Branded Typesは強力ですが、現場で導入する際にはいくつかお作法があります。

1. 生プリミティブの直接代入をガードする
`const id: UserId = “abc”` のような代入を防ぐため、基本的には `as` キャストをラップした専用のコンストラクタ関数(`createUserId` など)を経由させましょう。Zodなどのバリデーションライブラリを使っているなら、`.brand(“UserId”)` という機能が標準備え付けられているものもあるので、そちらを使うのも手です。
2. ログ出力やUI表示への影響は?
先ほど言った通り、ランタイムはただの `string` や `number` です。そのため、JSXの中で `{userId}` と書けば、そのまま画面に文字列として描画されます。`console.log` もそのまま出力できます。デバッグで困ることはまずありません。
3. チーム全体での共通認識を持つ
「なぜわざわざ面倒な型を作るのか?」をチームメンバー全員が理解していないと、邪魔くさがって `as any` で逃げる輩が出てきます。「バグを型でコンパイル時に潰すための防壁なんだ」というメリットをチームで共有してから導入してください。

—

まとめ

フロントエンドの規模が大きくなればなるほど、「型安全」の定義は `TypeError` を防ぐだけにとどまらなくなります。「ビジネスロジックの意図しない崩壊を防ぐ」ために、Branded Typesは今日からでも導入できる最高の武器です。

「たかがID、されどID」。
こういう細かい部分の気配りが、プロダクトの品質を何段階も引き上げ、君を「信頼できるシニアエンジニア」へと押し上げてくれます。

さあ、今日の午後からのリファクタリングで、コードベースのIDたちに「焼印」を押しまくってみませんか?

コメント

タイトルとURLをコピーしました