こんにちは!TypeScriptを触り始めると、なんだかコードがスイスイ書けて「型安全って素晴らしい!」って感動しますよね。でも、少しコードを書いていると、こんな不思議な現象に出会ったことはありませんか?
「あれ? この関数、本当は専用のIDを渡してほしいのに、全然関係ないただの文字列を渡してもエラーにならない……?」
実はこれ、TypeScriptの根底にある「構造的部分型(Structural Subtyping)」という強力な性質が原因で起こるものです。今回は、この仕組みの裏側と、意図しないバグを華麗に防ぐための「ブランド型(Branded Types)」という極上のテクニックを、優しく紐解いていきましょう。ここをクリアすれば、あなたのTypeScriptの型設計スキルは一段とプロの領域に近づきますよ!
—
1. 構造的部分型(Structural Subtyping)ってなんだろう?
他のオブジェクト指向言語(JavaやC#など)を触ったことがある人ほど、TypeScriptの型システムに最初戸惑います。
Javaなどでは、`UserId`というクラスやインターフェースがあったら、絶対に「UserId型」として明示的に作られたものしか代入できませんよね。これを「公称型(Nominal Typing)」と呼びます。
一方、TypeScriptは「アヒルのように歩き、アヒルのように鳴くなら、それはアヒルだ」という哲学(Duck Typing)をベースにした、構造的部分型を採用しています。つまり、「同じ形(構造)のプロパティを持っていれば、同じ型として扱っちゃうよ」というルールです。
実際にコードで見てみましょう
例えば、ユーザーIDと商品IDを管理するシステムを作っているとします。どちらも中身はただの文字列(`string`)です。
// インターフェースでそれぞれの型を定義したつもり
interface UserId {
value: string;
}
interface ProductId {
value: string;
}
function processUser(id: UserId) {
console.log(`ユーザーを処理します: ${id.value}`);
}
// 意図した使い方
const myUserId: UserId = { value: “user_123” };
processUser(myUserId); // 當然、これはOK
// 【恐怖の瞬間】
const myProductId: ProductId = { value: “prod_999” };
processUser(myProductId); // 诶!? エラーにならない……だと……?
おや? `processUser` は `UserId` を受け取るはずなのに、まったく別の概念である `ProductId` を渡しても、TypeScriptのコンパイラは「どちらも `value: string` という同じ形をしているから問題ないね!」とスルーしてしまいます。
これが、実務で恐ろしいバグ(商品のIDを間違えてユーザー検索に使ってしまうなど)を引き起こす「予期せぬ代入」の正体です。
—
2. なぜこの現象が起きるのか?(コンパイラ視点での話)
TypeScriptのコンパイラは、型を見るときに「名前(名札)」ではなく「中身(構造)」しか見ていません。
[UserId型] -> { value: string } ┐
├─> 構造が完全に一致!だから代入OK!
[ProductId] -> { value: string } ┘
コンパイラにとっては、名札に「ユーザー」と書いてあろうが「商品」と書いてあろうが、中身の段ボール箱の大きさと形が同じなら「同じもの」として扱われます。非常に合理的ではあるのですが、ドメインロジック(ビジネス上のルール)を厳密に守りたい私たち開発者にとっては、少しお節介すぎる挙動ですよね。
—
3. 「ブランド型(Branded Types)」でコンパイラをだます(?)いや、導く!
この構造的部分型の壁を突破し、コンパイラに「形は似ていても、名札が違うから別物だよ!」と教え込むテクニックが「ブランド型(別名:Nominal Typingの模倣)」です。
やり方はとってもシンプル。実際のデータには存在しない「偽りの目印(ブランド)」を、インターフェースにこっそり混ぜてあげるだけです。
ブランド型を使った厳格なインターフェース設計
// 1. ブランド(目印)となる一意なプロパティを交差型(&)などで追加する
interface UserId {
readonly __brand: unique symbol; // 絶対に被らないコンパイル時だけの目印
value: string;
}
interface ProductId {
readonly __brand: unique symbol;
value: string;
}
「なんじゃこりゃ?」と思いましたよね。この `__brand` というプロパティは、実際の実行時には存在しません(TypeScriptの型システムだけに存在する幻のプロパティです)。
しかし、これがあるおかげで、コンパイラの目にはこう映るようになります。
[UserId] -> { value: string, __brand: [Symbol A] } ┐
├─> ブランドが違うので代入不可!🚨
[ProductId] -> { value: string, __brand: [Symbol B] } ┘
早速、安全なコードを試してみましょう
function processUser(id: UserId) {
console.log(`ユーザーを処理します: ${id.value}`);
}
// — 安全なファクトリー関数を用意する —
// 生の文字列からブランド型へ「キャスト(昇格)」するための専用関数を作ります
function createUserId(id: string): UserId {
return { value: id } as UserId; // ここでだけ ‘as’ を使って型を強制する
}
function createProductId(id: string): ProductId {
return { value: id } as ProductId;
}
// 実戦投入!
const validUserId = createUserId(“user_123”);
const validProductId = createProductId(“prod_999”);
// 1. 正しい組み合わせ
processUser(validUserId); // 🟢 綺麗に通ります!
// 2. 予期せぬ代入の試み
processUser(validProductId);
// ❌ ここでTypeScriptが盛大にエラーを出してくれます!
// 「Argument of type ‘ProductId’ is not assignable to parameter of type ‘UserId’.
// Types of property ‘__brand’ are incompatible.」
やったー! ついにコンパイラが「おいおい、それはProductのIDだろ!UserのIDを渡しなよ!」と怒ってくれるようになりました。これで、うっかりミスによる重大なバグをコンパイル時に100%ブロックできます。
—
4. 現場で使える!ブランド型のスマートな書き方
毎回 `unique symbol` を書くのは少し長くて面倒ですよね。実務では、以下のような「ヘルパー型」を共通定義ファイルに一つ置いておくだけで、コードが劇的にスッキリします。
// ブランディング用の汎用ヘルパー型
type Brand
// 使用例:これだけでスッキリ書ける!
type UserId = Brand
type ProductId = Brand
// 値の生成もシンプルに
const userId = “user_abc” as UserId;
const productId = “prod_xyz” as ProductId;
function getUser(id: UserId) { / … / }
getUser(userId); // 🟢 OK
getUser(productId); // ❌ エラー!型安全最高!
—
まとめ:ここをクリアすれば、TypeScriptはあなたの最強の相棒になる!
今回は、TypeScriptの「構造的部分型」が引き起こす予期せぬ代入と、それを防ぐ「ブランド型」について解説しました。
- TypeScriptは形(構造)で型を判断するため、中身が同じプリミティブな型(`string`など)は区別されにくい。
- ブランド型(ダミーの目印)をインターフェースに付与することで、コンパイラに「別物だ」と認識させることができる。
- ドメインの境界線(ID管理、金額、メールアドレスなど、間違えたら致命的なもの)にブランド型を導入すると、堅牢性が爆発的に向上する。
「型安全って、ここまで厳しくできるんだ!」と感じていただけたなら嬉しいです。このテクニックをマスターすれば、大規模なフロントエンド・バックエンド開発でも、コードの意図が明確で怖くない、極上の開発体験が手に入りますよ。
それでは、また次回の知見でお会いしましょう!バッチリ使いこなしてくださいね!