はじめに
TypeScriptで開発していると、関数の引数の順番を間違えてしまい、意図しないバグを生んでしまった経験はないでしょうか?
例えば、以下のコードを見てください。userId
とaccountId
はどちらもstring
型のため、コンパイラは引数の間違いを検知してくれません。
function transferMoney(fromId: string, toId: string, amount: number) {
// 実装...
}
// 呼び出し時
const userId = "user123";
const accountId = "acc456";
// 意図しない使われ方だが、エラーにならない
transferMoney(accountId, userId, 1000);
// ↑ 引数の順番を間違えてもコンパイルエラーにならない!
このような「同じ型だが意味が異なる値」を区別できない問題を解決し、コードの型安全性を飛躍的に向上させるテクニックが Branded Types です。この記事では、Branded Typesの基本から実践的な使い方までを、解説します。
TypeScriptの構造的型付けとその課題
Branded Typesを理解するために、まずはTypeScriptの型システムである 構造的型付け(Structural Typing) についておさらいしましょう。
構造的型付けは、「型の構造が一致していれば、名前が違っても互換性がある」とみなすシステムで、TypeScriptの他にGoなどがこれに分類されます。
これに対し、名前的型付け(Nominal Typing)は、「型の名前が同じでなければ互換性がない」とみなすシステムで、Java, C#, C++, Swift, Rustなどが採用しています。
interface User {
id: string;
name: string;
}
interface Product {
id: string;
name: string;
}
function processUser(user: User) {
console.log(user.name);
}
const product: Product = { id: "p1", name: "商品A" };
processUser(product); // エラーにならない!
User
とProduct
は名前が違いますが、プロパティの構造が同じなので、TypeScriptはこれらを互換性のある型として扱います。これは便利な反面、冒頭の例のように、意図しない型を受け入れてしまうという課題も抱えています。
Branded Typesとは?
Branded Typesは、TypeScriptに名前的型付けの考え方を導入するための手法です。
具体的には、プリミティブ型に__brand
というユニークなプロパティを交差型(&
)で付与することで、型に「ブランド」を付けます。これにより、ベースの型が同じでも、ブランドが異ればTypeScriptが別の型として認識してくれるようになります。
// 構造的型付けの問題
type UserId = string;
type AccountId = string;
// UserId と AccountId は互換性がある(ただの別名)
// Branded Types で解決
type UserId = string & { readonly __brand: unique symbol };
type AccountId = string & { readonly __brand: unique symbol };
// これで UserId と AccountId は互換性がなくなる!
このテクニックの利点は、同じプリミティブ型(string
やnumber
)であっても、意味的に区別できるようになることです。
2つの実装パターン
Branded Typesにはいくつかの実装パターンがありますが本記事では2つの実装パターンを紹介します。どちらも実用上は同じように機能します。
パターン 1: unique symbol(最も厳密)
unique symbol
を使うことで、他のコードとのブランド名の衝突を完全に避けることができます。
type UserId = string & { readonly __brand: unique symbol };
type ProductId = string & { readonly __brand: unique symbol };
パターン 2: リテラル型(実用的)
文字列リテラルをブランドとして使う方法で、シンプルで理解しやすいのが特徴です。ジェネリック型を使うと便利に定義できます。
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
実践例
例1: バリデーション済みかどうかの判断
ユーザー入力のように、検証が必要な値を型で表現する例を見てみましょう。
❌ Branded Typesを使わない場合
バリデーション済みかどうかが型で区別できないため、未検証のデータもデータベースに保存できてしまいます。
function validateUserInput(raw: string): string {
// ...
return sanitized;
}
function saveToDatabase(input: string) {
db.save(input); // 未検証データも保存できてしまう
}
function handleSubmit() {
const rawInput = userInput.value;
// 危険!バリデーションを忘れても型エラーにならない
saveToDatabase(rawInput); // コンパイルエラーなし 😱
}
✅ Branded Typesを使った場合
「検証済み」という状態を型で表現することで、バリデーションのし忘れをコンパイル時に防ぐことができます。
// バリデーション済みデータのみBranded Types
type ValidatedUserInput = string & { __brand: "ValidatedUserInput" };
function validateUserInput(raw: string): ValidatedUserInput {
// ...
return sanitized as ValidatedUserInput;
}
// バリデーション済みデータのみを受け付ける
function saveToDatabase(input: ValidatedUserInput) {
db.save(input); // 型で安全性が保証される
}
function handleSubmit() {
const rawInput = userInput.value; // 普通のstring型
// saveToDatabase(rawInput); // 型エラー!未検証データは保存できない ✋
const validated = validateUserInput(rawInput);
saveToDatabase(validated); // OK!検証済みなので安全 ✅
}
例2: ドメインモデルでの活用
UserId
やProductId
のように、ドメインにおける様々なIDをBranded Typesで区別することで、より安全なコードになります。
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId): User { /* ... */ }
function getProduct(id: ProductId): Product { /* ... */ }
function createOrder(userId: UserId, productId: ProductId): OrderId {
// userId と productId の型が保証されている
// ...
}
// 使用例
const userId = "user_123" as UserId;
const productId = "prod_456" as ProductId;
const order = createOrder(userId, productId); // OK
// const order2 = createOrder(productId, userId); // 型エラー!引数の順番が違う
例3: コンストラクタ関数を使った改善
"値" as UserId
のような型アサーションをコードのあちこちに書くのは、あまり美しくありません。
そこで、型アサーションを隠蔽するコンストラクタ(ファクトリ)関数を用意することで、より安全に値を作成できます。
// 型アサーションを隠蔽するコンストラクタ
const UserId = {
create: (value: string): UserId => {
if (!value) throw new Error("UserId cannot be empty");
return value as UserId;
},
};
const ProductId = {
create: (value: string): ProductId => {
if (!value) throw new Error("ProductId cannot be empty");
return value as ProductId;
},
};
// より安全な使用例
const userId = UserId.create("user123");
const productId = ProductId.create("prod456");
// 直接型アサーションする必要がない
// const badUserId = "user123" as UserId; // これを避けられる
const goodUserId = UserId.create("user123"); // こちらを推奨
パフォーマンスへの影響
Branded Typesはコンパイル時にのみ機能し、型情報はコンパイル後のJavaScriptコードからはすべて消え去ります。
// TypeScriptで書いたコード
type UserId = string & { __brand: "UserId" };
const userId: UserId = "user123" as UserId;
// ↓ コンパイル後のJavaScript
const userId = "user123"; // 型情報は全て消える!
結果として、ブラウザで動くJavaScriptは普通のstring
と全く同じです。
- ランタイムへの影響: メモリ使用量、実行速度、ファイルサイズ、すべてにおいて影響はありません。
- コンパイル時への影響: わずかな型チェック時間の増加はありますが、実用上問題になるレベルではありません。
導入時の注意点
Branded Typesを導入する際のメリットとデメリットを整理します。
メリット ✅
- バグの早期発見: コンパイル時に型の不一致を検出できます。
-
APIの意図が明確:
(id: UserId)
のように、関数シグネチャが自己文書化されます。 - リファクタリング時の安全性: 型の変更による影響範囲が明確になります。
デメリット ⚠️
- 学習コスト: チームメンバーがこの概念を理解する必要があります。
-
型アサーション: 値を生成する際に
as
が必要になります(コンストラクタ関数で隠蔽推奨)。 - 既存コードへの影響: 既存のコードベースに導入する場合は、段階的な移行計画が必要です。
まとめ
- Branded Types はTypeScriptの型安全性を向上させる強力な手法です。
- TypeScriptの構造的型付けが持つ限界を克服し、名前的型付けの利点を享受できます。
- 実装は
unique symbol
やリテラル型を使い、簡単に行うことができます。
日々の開発にBranded Typesを取り入れて、より堅牢でメンテナンスしやすいコードベースを築いていきましょう。