9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TypeScriptシリーズ - Part 5] Branded Types

9
Posted at

images.jpeg

📝 注記
私は日本語が得意ではありません。この記事はAIの翻訳サポートを受けて書いています。ご了承ください。

📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. Playgroundリンク – その場で試せる
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

1. 問題の提示 – どんな時にこのテクニックが必要か

あなたはECサイトのバックエンドをTypeScriptで開発しています。以下のような要件があります。

// 商品IDと注文IDはどちらも文字列だが、混同してはいけない
function getProductById(id: string): Product { ... }
function getOrderById(id: string): Order { ... }

const productId = "prod_123";
const orderId = "ord_456";

// これはバグなのにTypeScriptは検出できない
getOrderById(productId); // 😱 コンパイルエラーにならない!

問題点:

  • 同じ構造(string)を持つ異なる概念を型システムが区別できない
  • UserIdOrderIdEmailPasswordSanitizedStringRawString が混同される
  • 関数に間違ったIDを渡してもエラーにならない
  • リファクタリング時に重大なバグを見逃す可能性がある

問いかけ:

どうすれば構造は同じでも異なる意味を持つ型をTypeScriptに区別させられるでしょうか?


2. 悪い例 – まずはダメなコードを見せる

// ❌ プリミティブ型のままでは区別できない

type UserId    = string;
type OrderId   = string;
type ProductId = string;

function getUserById(id: UserId): User {
  return { id, name: "Alice" };
}

function getOrderById(id: OrderId): Order {
  return { id, total: 100 };
}

function getProductById(id: ProductId): Product {
  return { id, name: "Laptop" };
}

// すべてstringなので、間違えてもエラーにならない 😱
const userId: UserId   = "user_123";
const orderId: OrderId = "order_456";

getOrderById(userId);    // 😱 UserIdをOrderIdとして渡せる
getProductById(orderId); // 😱 OrderIdをProductIdとして渡せる

// 数値の場合も同様
type PositiveNumber = number;
type Percentage     = number;

function setPositive(value: PositiveNumber) { /* ... */ }
function setPercentage(value: Percentage) { /* ... */ }

setPositive(-5);    // 負の数でも通ってしまう
setPercentage(150); // 100を超えても通ってしまう

なぜ悪いのか:

問題 説明
型の別名はnominalでない TypeScriptは構造的部分型(structural typing)を使用
同じ構造なら同じ型とみなす string はすべて stringnumber はすべて number
実行時エラーのリスク IDの混同は発見が難しいバグを生む
セマンティックな意味を表現できない 型から「これは何を表すか」が読み取れない

3. 良い例 – TypeScriptの高度機能で解決する

基本: Branded Typesとは?

Branded Types(ブランド型 / Opaque Types)は、既存の型に存在しないプロパティを追加することで、型システム上だけ区別できるようにするテクニックです。

// 基本形: 交差型(intersection type)を使ってブランドを追加
type Branded<T, Brand> = T & { __brand: Brand };

// 使用例
type UserId  = Branded<string, "UserId">;
type OrderId = Branded<string, "OrderId">;

ユースケース1: ID型の区別

// ✅ Branded TypesでIDを区別する

type Brand<T, B> = T & { __brand: B };

type UserId    = Brand<string, "UserId">;
type OrderId   = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;

function getUserById(id: UserId): User {
  return { id: id as string, name: "Alice" };
}

function getOrderById(id: OrderId): Order {
  return { id: id as string, total: 100 };
}

const userId  = "user_123"  as UserId;
const orderId = "order_456" as OrderId;

// ✅ 正しい使い方
getUserById(userId);   // OK
getOrderById(orderId); // OK

// ❌ 間違った使い方はコンパイルエラー
getOrderById(userId);  // エラー: UserIdはOrderIdに代入できない
getUserById(orderId);  // エラー: OrderIdはUserIdに代入できない

ユースケース2: 値のバリデーション(正の整数)

// ✅ 正の整数を表現するBranded Type

type Positive = number & { __brand: "positive" };

// バリデーション関数(type predicate)
function isPositive(value: number): value is Positive {
  return value > 0;
}

// アサーション関数
function assertPositive(value: number): asserts value is Positive {
  if (value <= 0) {
    throw new Error(`${value} is not positive`);
  }
}

// コンストラクタ関数
function createPositive(value: number): Positive {
  if (value <= 0) {
    throw new Error(`${value} is not positive`);
  }
  return value as Positive;
}

function waitForSeconds(seconds: Positive) {
  return new Promise(resolve => setTimeout(resolve, seconds * 1000));
}

// ✅ 正しい値
await waitForSeconds(createPositive(5));

// ✅ type predicateを使った安全なガード
const input = 10;
if (isPositive(input)) {
  await waitForSeconds(input); // inputはPositive型に推論される
}

ユースケース3: サニタイズされた文字列

// ✅ XSS攻撃を防ぐBranded Type

type SafeString = string & { __sanitized: true };

function sanitize(input: string): SafeString {
  const escaped = input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
  return escaped as SafeString;
}

// DOMに書き込む関数(サニタイズ済み文字列のみ受け付ける)
function writeToDOM(content: SafeString) {
  document.body.innerHTML += content;
}

const userInput = '<script src="evil.js"></script>';

// ❌ サニタイズしていない文字列は渡せない
writeToDOM(userInput); // エラー: stringはSafeStringに代入できない

// ✅ サニタイズ後なら安全
const safe = sanitize(userInput);
writeToDOM(safe); // OK

ユースケース4: 通貨の区別

// ✅ 異なる通貨を型で区別する

type Currency<T> = number & { __currency: T };

type USD = Currency<"USD">;
type EUR = Currency<"EUR">;
type JPY = Currency<"JPY">;

interface PricedProduct {
  name: string;
  price: USD;
}

function usdToEur(usd: USD): EUR {
  return (usd * 0.92) as EUR;
}

const product: PricedProduct = {
  name: "Coffee",
  price: 5 as USD
};

// ✅ 正しい通貨
const eurPrice: EUR = usdToEur(product.price);

// ❌ 異なる通貨は直接代入できない
const wrongPrice: EUR = product.price; // エラー: USDはEURに代入できない

ユースケース5: ジェネリックなBranded Type

// ✅ 再利用可能なBranded Typeユーティリティ

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId    = Brand<string, "UserId">;
type PostId    = Brand<string, "PostId">;
type CommentId = Brand<string, "CommentId">;

type Age        = Brand<number, "Age">;
type Percentage = Brand<number, "Percentage">;

function createAge(value: number): Age {
  if (value < 0 || value > 150) {
    throw new Error(`Invalid age: ${value}`);
  }
  return value as Age;
}

function createPercentage(value: number): Percentage {
  if (value < 0 || value > 100) {
    throw new Error(`Invalid percentage: ${value}`);
  }
  return value as Percentage;
}

ユースケース6: コミュニティライブラリ(Effect TS)

⚠️ 注意: Effect TSのAPIはバージョンによって変更されることがあります。実際に使用する場合は公式ドキュメントで最新のAPIを確認してください。

// Effect TSを使ったBranded Type(概念の紹介)
// 実際のAPIはバージョンによって異なります

import { Brand } from "effect";

// EffectのBrand機能を使用(コンセプト)
type UserId = string & Brand.Brand<"UserId">;
type Email  = string & Brand.Brand<"Email">;

// 詳細な実装は公式ドキュメントを参照してください
// https://effect.website/docs/guides/style/branded-types

処理の流れ(視覚モデル)

Branded Typeの仕組み:

string (プリミティブ)
    │
    │ 交差型 (&) で結合
    ▼
string & { __brand: "UserId" }
    │
    ├── ランタイム: ただの文字列
    │   (__brandプロパティは存在しない)
    │
    └── 型システム: UserIdとして区別される
            他のBranded<string, ...>と互換性なし

4. Playgroundリンク – その場で試せる

理論だけでは実感しにくいので、実際に動かして確認してみましょう。
TypeScript Playgroundはブラウザ上でTypeScriptを実行できる公式ツールです。インストール不要、すぐに試せます。

🔗 Playground URL: https://www.typescriptlang.org/play/

何を確認できるのか?

下のコードをコピーしてPlaygroundに貼り付けた後、コメントアウトされた ❌ エラー の行のコメントを外してみてください。TypeScriptが実際にエラーを出し、Branded Typesによる型の区別が機能していることが確認できます。

// ① このコードをコピーしてPlaygroundに貼り付ける
type Brand<T, B extends string> = T & { __brand: B };

type UserId  = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

type Positive   = number & { __brand: "positive" };
type SafeString = string & { __sanitized: true };

function isPositive(value: number): value is Positive {
  return value > 0;
}

function createPositive(value: number): Positive {
  if (value <= 0) throw new Error("Not positive");
  return value as Positive;
}

function sanitize(input: string): SafeString {
  return input.replace(/</g, "&lt;") as SafeString;
}

type Currency<T> = number & { __currency: T };
type USD = Currency<"USD">;
type EUR = Currency<"EUR">;

// ② 使用例
const userId  = "user_123"  as UserId;
const orderId = "order_456" as OrderId;

// ③ コメントを外してエラーを確認しよう
// const wrong: OrderId = userId;  // ❌ エラー

const five = createPositive(5);
const safe = sanitize("<script>alert('xss')</script>");
const dollars = 100 as USD;

ホバーすると何が見える?

userId にホバーすると UserId 型、five にホバーすると Positive 型が表示されます。コメントアウトを外した行では、異なるBranded Type間の代入が禁止されていることが視覚的に確認できます。


5. 課題 – シニア向けのチャレンジ問題

課題1: メールアドレスのBranded Type

メールアドレスを表す Email 型を作成し、以下の要件を満たしてください。

  • 通常の string から Email への変換はバリデーションを通過する必要がある
  • 有効なメールアドレスの形式をチェックする(例:xxx@yyy.zzz
  • バリデーション関数、アサーション関数、コンストラクタ関数をそれぞれ実装

💡 ヒント: isEmail(type predicate)、assertEmail(asserts)、createEmail(コンストラクタ)の3パターンで実装します。

✅ 解答を見る(クリック)
type Email = string & { __brand: "email" };

function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

// type predicate
function isEmail(value: string): value is Email {
  return isValidEmail(value);
}

// アサーション関数
function assertEmail(value: string): asserts value is Email {
  if (!isValidEmail(value)) {
    throw new Error(`Invalid email: ${value}`);
  }
}

// コンストラクタ
function createEmail(value: string): Email {
  if (!isValidEmail(value)) {
    throw new Error(`Invalid email: ${value}`);
  }
  return value as Email;
}

function sendEmail(to: Email, subject: string) {
  console.log(`Sending to ${to}: ${subject}`);
}

const validEmail = createEmail("user@example.com");
sendEmail(validEmail, "Hello");  // OK

// sendEmail("not-an-email", "Hello");  // ❌ エラー

課題2: パスワードのBranded Type(強度要件付き)

以下の要件を満たす StrongPassword 型を作成してください。

  • 最低8文字以上
  • 大文字、小文字、数字をそれぞれ1文字以上含む
  • バリデーションに通った文字列のみが StrongPassword として扱える

💡 ヒント: 正規表現で各条件をチェックし、type predicateと組み合わせます。

✅ 解答を見る(クリック)
type StrongPassword = string & { __brand: "strong-password" };

function isStrongPassword(value: string): value is StrongPassword {
  const hasUpperCase = /[A-Z]/.test(value);
  const hasLowerCase = /[a-z]/.test(value);
  const hasNumber    = /[0-9]/.test(value);
  const hasMinLength = value.length >= 8;
  return hasUpperCase && hasLowerCase && hasNumber && hasMinLength;
}

function createStrongPassword(value: string): StrongPassword {
  if (!isStrongPassword(value)) {
    throw new Error("Password is not strong enough");
  }
  return value as StrongPassword;
}

const password = createStrongPassword("MyPassword123");  // OK
// const weak = createStrongPassword("weak");            // ❌ エラー

課題3: ユニオン型との比較

以下のシナリオでは、Branded Typeとユニオン型のどちらが適切か判断してください。

  1. タスクの状態: "pending", "in-progress", "completed", "archived"
  2. ユーザーID: 文字列だが、通常の文字列と区別したい
  3. 温度: 摂氏(Celsius)と華氏(Fahrenheit)を区別したい

💡 ヒント: 値が有限に決まっているか、値が無限に取りうるかで判断します。

✅ 解答を見る(クリック)
// 1. タスク状態 → ユニオン型が適切(値が限られている)
type TaskStatus = "pending" | "in-progress" | "completed" | "archived";

// 2. ユーザーID → Branded Typeが適切(値は無限、意味を区別したい)
type UserId = string & { __brand: "UserId" };

// 3. 温度 → Branded Typeが適切(同じnumberだが単位を区別したい)
type Celsius    = number & { __unit: "C" };
type Fahrenheit = number & { __unit: "F" };

function toCelsius(f: Fahrenheit): Celsius {
  return ((f - 32) * 5 / 9) as Celsius;
}

課題4(ボーナス): SQL Injection対策のBranded Type

ユーザー入力をそのままSQLクエリに埋め込むのを防ぐため、SafeSqlString 型を実装してください。

  • 通常の string からは直接 SafeSqlString を作れない
  • 専用の sanitizeForSql 関数を経由する必要がある
  • サニタイズ関数はSQLインジェクションの危険な文字をエスケープする

⚠️ 注意: 以下のサニタイズ実装はあくまで概念の説明用です。実際のプロダクションではパラメータ化クエリ(Prepared Statements)を使用してください。

💡 ヒント: SafeSqlString 型を定義し、sanitizeForSql 関数でのみ as キャストを使えるようにします。

✅ 解答を見る(クリック)
type SafeSqlString = string & { __sanitizedForSql: true };

// ⚠️ 概念説明用の簡易実装です
// 実際のプロダクションではPrepared Statementsを使用してください
function sanitizeForSql(input: string): SafeSqlString {
  const escaped = input
    .replace(/'/g, "''")
    .replace(/\\/g, "\\\\")
    .replace(/;/g, "");
  return escaped as SafeSqlString;
}

function executeQuery(sql: SafeSqlString) {
  console.log(`Executing safe SQL: ${sql}`);
}

const userInput = "'; DROP TABLE users; --";
const safeSql = sanitizeForSql(
  `SELECT * FROM users WHERE name = '${userInput}'`
);

executeQuery(safeSql);   // OK
// executeQuery(userInput);  // ❌ エラー: stringはSafeSqlStringに代入できない

6. まとめ

今日学んだこと

概念 説明
Branded Types 同じ構造の型を区別するためのテクニック
交差型(& 既存の型にブランドプロパティを追加
ブランドプロパティ ランタイムには存在しない、型システムだけのマーカー
Type Predicate value is BrandedType で型を絞り込む
Assertion Function asserts value is BrandedType でバリデーション
Effect TS 洗練されたBranded Type機能を提供するライブラリ

いつBranded Typesを使うべきか?

✅ 使うべき場合 ❌ 使わなくていい場合
ID型(UserId, OrderIdなど)を区別したい 値が有限でユニオンで表現できる
バリデーション済みの値(Positive, Emailなど) 単純な型エイリアスで十分な場合
単位や意味を型で表現したい 小さなスクリプトやプロトタイプ
ドメイン駆動設計(DDD)の値オブジェクト

シニアへのアドバイス

構造が同じでも意味が違うものは、Branded Typesで区別しましょう。ただし、すべてのプリミティブをブランド化する必要はありません。本当に混同すると危険な場所だけに使うのが良いプラクティスです。
また、as アサーションに頼りすぎると型安全の意味が薄れるので、可能な限りバリデーション関数を経由することをお勧めします。

** Have a nice day! 🚀**

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?