不透明型

型の内部構造を隠蔽し、モジュール外部からの直接操作を防ぐカプセル化テクニック

TypeScriptオブジェクト指向

不透明型とは

不透明型 (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;
}

// 外部ではブランドのキー名を書けないため、値を組み立てて代入する経路は塞がれる
// const email: Email = 'test@example.com'; // 代入不可
// 一方、型アサーションは基底型からの絞り込みとして成立するので止まらない
// const email = 'test@example.com' as Email; // これはコンパイルエラーにならない

unique symbol が塞ぐのは「ブランドのキー名を外部で書くこと」である。同じ形の型を別のモジュールで宣言して代入する迂回路はこれで消えるし、EmailUserId のようにブランドの異なる型を直接キャストするのもエラーになる。一方で as Email そのものは基底型 string からの絞り込みとして成立するため、外部からでも通ってしまう (TypeScript 6.0 で実測)。つまり生成経路を createEmail に固定したいなら、型定義だけでは足りない。型アサーションを lint やコードレビューで禁じる運用と組み合わせて初めて、バリデーション済みという前提が守られる。

ブランド型と不透明型の違い

ブランド型と不透明型の主な違いを以下に整理する。

観点ブランド型不透明型
外部からのキャストas で可能as で可能 (基底型からの絞り込み)
外部での型の偽造同じ形の型を宣言して代入できるキー名が書けないため塞がれる
バリデーション保証弱い (キャストで迂回可能)型だけでは完結せず、アサーション禁止の運用が前提
実装の複雑さシンプルやや複雑
適するケースチーム内の軽い型安全性ライブラリの公開 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#123ORDER#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;
}

他言語での不透明型

他言語での不透明型を以下にまとめる。

言語実現方法
Haskellnewtype で既存型をラップ (ランタイムコストゼロ)
Rustタプル構造体 struct Email(String)
Flowopaque type キーワード (言語レベルでサポート)
Scalaopaque type (Scala 3)
TypeScriptブランド型 + unique symbol (ワークアラウンド)

TypeScript は言語レベルで不透明型をサポートしていないため、ブランド型のテクニックで代用する。Flow の opaque type は、定義したファイルの外では内部の型を隠すという言語仕様であり、キャストの抜け道を運用で塞ぐ必要がない点が TypeScript との差になる。

よくある落とし穴

  • 過剰な適用: すべての string を不透明型にすると、コードが冗長になる。セキュリティ上重要な値 (パスワード、トークン) や、取り違えが深刻なバグにつながる値 (各種 ID) に限定して適用する
  • ランタイムの型消去: TypeScript の型情報はコンパイル時に消去される。不透明型はコンパイル時の安全性のみを提供し、ランタイムでの型チェックは別途必要
  • シリアライズ/デシリアライズ: JSON からデシリアライズした値は string 型になる。API レスポンスの受け取り時にコンストラクタ関数を通す処理を忘れないこと
  • 二段キャストの抜け道: value as unknown as Email はブランドの種類を問わず通る。型で塞ぎきれる前提を置かず、値の生成箇所をレビューの観点に入れる

「Programming TypeScript」(Boris Cherny 著) や「Effective TypeScript」(Dan Vanderkam 著) で、ブランド型と型安全性のテクニックが解説されている。

基礎から学ぶなら関連書籍が手がかりになる。

この記事は役に立ちましたか?

関連用語

関連する記事