不透明型
型の内部構造を隠蔽し、モジュール外部からの直接操作を防ぐカプセル化テクニック
不透明型とは
不透明型 (Opaque Type) は、型の内部表現 (例: string) を外部に公開せず、専用のコンストラクタ関数を通じてのみ値を生成できるようにするカプセル化テクニックである。「この値はバリデーション済みである」ことを型システムで保証し、不正な値の混入をコンパイル時に防ぐ。
問題: プリミティブ型の取り違え
TypeScript では string 型の値はすべて互換性がある。メールアドレスもユーザー ID も URL も、型システム上は同じ string だ。
function sendEmail(to: string, subject: string, body: string) { ... }
// 引数の順番を間違えても、コンパイルエラーにならない
sendEmail(subject, to, body); // バグだが型チェックを通過する
不透明型はこの問題を解決する。Email 型と Subject 型を別の型として定義すれば、取り違えはコンパイルエラーになる。
TypeScript での実装
ブランド型 (基本形)
type Email = string & { readonly __brand: 'Email' };
type UserId = string & { readonly __brand: 'UserId' };
// Email と UserId は互換性がない
function sendEmail(to: Email, subject: string) { ... }
const userId: UserId = 'user-123' as UserId;
sendEmail(userId, 'Hello'); // コンパイルエラー ✅
ただし、ブランド型は as でキャストすれば外部からも生成できてしまう。
不透明型 (強化版)
// opaque-types.ts
declare const __brand: unique symbol;
type Email = string & { readonly [__brand]: 'Email' };
// モジュール内でのみ Email を生成できる
export function createEmail(input: string): Email {
if (!/^[^@]+@[^@]+\.[^@]+$/.test(input)) {
throw new Error('Invalid email format');
}
return input as Email;
}
// 外部からは unique symbol にアクセスできないため、キャスト不可能
// const email: Email = 'test@example.com' as Email; // 実質的にコンパイルエラー
unique symbol を使うことで、モジュール外部からのキャストを実質的に不可能にする。値の生成は必ず createEmail を経由するため、バリデーションが保証される。
ブランド型と不透明型の違い
| 観点 | ブランド型 | 不透明型 |
|---|---|---|
| 外部からのキャスト | as で可能 |
unique symbol で実質不可能 |
| バリデーション保証 | 弱い (キャストで迂回可能) | 強い (コンストラクタ経由を強制) |
| 実装の複雑さ | シンプル | やや複雑 |
| 適するケース | チーム内の軽い型安全性 | ライブラリの公開 API、セキュリティ要件 |
実務での活用パターン
ID の取り違え防止
type UserId = string & { readonly [__brand]: 'UserId' };
type OrderId = string & { readonly [__brand]: 'OrderId' };
// UserId と OrderId を間違えるとコンパイルエラー
async function getOrder(orderId: OrderId): Promise<Order> { ... }
const userId = createUserId('user-123');
getOrder(userId); // コンパイルエラー ✅
DynamoDB のシングルテーブル設計では、PK に USER#123、ORDER#456 のようなプレフィックス付き ID を使う。不透明型で ID の種類を区別すれば、誤ったパーティションキーでクエリするバグを防げる。
セキュリティ上の区別
type PlainPassword = string & { readonly [__brand]: 'PlainPassword' };
type HashedPassword = string & { readonly [__brand]: 'HashedPassword' };
// ハッシュ化済みパスワードと平文パスワードを型で区別
function hashPassword(plain: PlainPassword): HashedPassword { ... }
function verifyPassword(plain: PlainPassword, hashed: HashedPassword): boolean { ... }
// 平文パスワードをそのまま DB に保存しようとするとコンパイルエラー
function saveUser(name: string, password: HashedPassword) { ... }
バリデーション済み URL
type ValidUrl = string & { readonly [__brand]: 'ValidUrl' };
export function parseUrl(input: string): ValidUrl {
new URL(input); // 不正な URL なら例外
return input as ValidUrl;
}
他言語での不透明型
| 言語 | 実現方法 |
|---|---|
| Haskell | newtype で既存型をラップ (ランタイムコストゼロ) |
| Rust | タプル構造体 struct Email(String) |
| Flow | opaque type キーワード (言語レベルでサポート) |
| Scala | opaque type (Scala 3) |
| TypeScript | ブランド型 + unique symbol (ワークアラウンド) |
TypeScript は言語レベルで不透明型をサポートしていないため、ブランド型のテクニックで模倣する。Flow は opaque type を言語仕様として持っており、より自然に記述できる。
よくある落とし穴
- 過剰な適用: すべての
stringを不透明型にすると、コードが冗長になる。セキュリティ上重要な値 (パスワード、トークン) や、取り違えが深刻なバグにつながる値 (各種 ID) に限定して適用する - ランタイムの型消去: TypeScript の型情報はコンパイル時に消去される。不透明型はコンパイル時の安全性のみを提供し、ランタイムでの型チェックは別途必要
- シリアライズ/デシリアライズ: JSON からデシリアライズした値は
string型になる。API レスポンスの受け取り時にコンストラクタ関数を通す処理を忘れないこと
「Programming TypeScript」(Boris Cherny 著) や「Effective TypeScript」(Dan Vanderkam 著) で、ブランド型と型安全性のテクニックが解説されている。
基礎から学ぶなら関連書籍が手がかりになる。