✍️

[TS TIP] タグ付きユニオン型とブランド型

に公開

最初に

業務でも取り入れやすいtypescriptの魅力を活かせるtipシリーズです。

今回はタイトルにもある通り、タグ付きユニオン型・ブランド型について触れていきます。
基本的にはコードベースで解説を行い、最後には2つの型を組み合わせた場合の実践例もまとめています。

では始めます。

タグ付きユニオン型とは

タグ付きユニオン型(Tagged Union Types)は、異なる型の集合(ユニオン型)に識別子(タグ)を付けることで、型の安全性と保守性を高める手法です。これは「判別可能なユニオン型(Discriminated Unions)」とも呼ばれます。

タグ付きユニオン型の活用例

関数内で異なる型であることを判定し、処理を分岐させる際に重宝します。

例えば以下のように、タグがない状態だと関数内で型毎の分岐処理の判断をつけるのが難しくなります。

non-tag-union.ts
type Animal = Dog | Cat;

type Dog = {
  age: number;
  description: string;
};

type Cat = {
  age: number;
  voice: number;
  description: string;
};

const testFun(animal: Animal): number {
  // どの型なのか判別が難しい(どう判断させる?)
  if (/* ??? */) {
    return shape.age * voice;
  } else {
    return shape.age;
  }
}

これにタグをつけるだけで、関数内で指定のユニオン型であれば難なく処理を分岐できます。

tag-union.ts
type Animal = Dog | Cat;

type Dog = {
  type: "dog";
  age: number;
  description: string;
};

type Cat = {
  type: "cat"
  age: number;
  voice: number;
  description: string;
};

const dogData: Dog = {
  type: "dog"
  age: 3,
  description: ""
}

const catData: Cat = {
  type: "cat"
  age: 3,
  voice: 2,
  description: "voice にゃ〜 → 2ゃ〜"
}

const testFun(animal: Animal): number {
  switch(animal.type) {
    case "dog":
        return animal.age
    case "cat":
        return animal.age * animal.voice
    default:
        return 0
    }
}

testFun(dogData) // → 3
testFun(catData) // → 5

タグ付きユニオン型のメリット

  • 型安全性の向上:コンパイラが各型を正確に識別できるため、プロパティへのアクセスが安全になります。
  • 補完機能の強化:型によって利用可能なプロパティが自動的に絞り込まれます。
  • 網羅性チェック:すべてのケースを処理しているかコンパイラがチェックできます。
    • こちらは、switch文にdefaultをつけると機能しません。
  • 保守性の向上:新しい型を追加した際にコンパイルエラーで修正箇所が明確になります。

ブランド型とは

ブランド型(Branded Types)は、同じ構造を持つ型を「ブランド」によって区別する手法です。この手法は、特に基本的な型(文字列や数値など)を用途によって区別したい場合に非常に有効です。

例えば、ユーザーIDと商品IDはどちらも文字列型かもしれませんが、それらを混同して使用することは避けたいケースがあります。

ブランド型は、通常インターセクション型と空のインターフェースを組み合わせて実装します。

// ブランドを表す型
interface UserIdBrand {
  readonly __brand: unique symbol;
}

// ブランド型の定義
type UserId = string & UserIdBrand;

これを実際に使用するには以下のようにします。

const createUserId(id: string): UserId => {
  return id as UserId;
}

const getUserById = (id: UserId) => {
  // データベースからユーザーを取得する処理
}

// 正しい使用例
const userId = createUserId('12345');
getUserById(userId); // OK

// 誤った使用例
getUserById('12345'); // コンパイルエラー

オブジェクト単位で区別したい場合(tsは部分的構造型を採用しているので、型名が違くても中身が一緒なら、エラーを起こさずに処理できます)は以下のようになります。

brand.ts
// 共通の Brand 型
type Brand<T, B> = T & { readonly _brand: B }

type User = Brand<{
    id: number,
    name: string
}, "User">

type Task = Brand<{
    id: number,
    name: string
}, "Task">

// コンストラクタ関数
const createUser = (obj: { id: number; name: string }): User => {
    return obj as User
}
const createTask = (obj: { id: number; name: string }): Task => {
    return obj as Task
}

const userTestData: User = createUser({
    id: 1,
    name: "taro"
})
const taskTestData: Task = createTask({
    id: 2,
    name: "jiro"
})

const getUserByUserType = (data: User) => {
    // なんらかの処理
}

getUserByUserType(userTestData)
getUserByUserType(taskTestData) // コンパイルエラー

ブランド型のメリット

  • 型安全性の向上: 構造的には同じでも意味的に異なる型を区別できます。これにより、間違った値が誤って渡されることを防ぎます。
  • コンパイル時の検証: ブランド型の最大の利点はコンパイル時に型の誤用を検出できることです。実行時エラーよりも早い段階でバグを発見できます。
  • 自己文書化コード: ブランド型は、コード内での値の意図を明確に示します。変数の型を見るだけで、その値が何を表しているのかが分かります。

タグ付きとブランドを組み合わせた実践的なユースケース

「支払い処理」を例に考えてみます。

着目点は以下です。

  • 支払IDと顧客IDは文字列だがセキュリティおよびシステムの扱いやすさの観点から別物として扱いたい。
  • 支払処理を一つの関数内で分岐したい。
    • 例えば、開発の観点からフロントは「/payment」のエンドポイントを呼ぶだけで良いようにすることでバックエンド側に責務を持たせたい場合などが考えられるでしょうか。
payment.ts
// ブランド型の定義
type Brand<T, B> = T & { readonly _brand: B }

type PaymentId = Brand<string, 'PaymentId'>;
type CustomerId = Brand<string, 'CustomerId'>;

// IDの生成関数
const createPaymentId = (id: string): PaymentId => id as PaymentId;
const createCustomerId = (id: string): CustomerId => id as CustomerId;

// タグ付きユニオン型を用いた支払い方法の定義
type PaymentMethod =
  | CreditCard
  | BankTransfer
  | Paypal

type CreditCard = {
    type: 'credit_card';
    cardNumber: string;
    expiry: string;
    securityCode: string
}

type BankTransfer = {
    type: 'bank_transfer';
    accountNumber: string;
    routingNumber: string
}

type Paypal = {
    type: 'paypal';
    email: string;
}

// 支払い処理関数
const processPayment = (
  paymentId: PaymentId, 
  customerId: CustomerId, 
  amount: number, 
  method: PaymentMethod
) => {
  console.log(`Processing payment ${paymentId} for customer ${customerId}`);
  
  switch (method.type) {
    case 'credit_card':
      console.log(`Charging ${amount} to card ending in ${method.cardNumber.slice(-4)}`);
      break;
    case 'bank_transfer':
      console.log(`Transferring ${amount} from account ${method.accountNumber}`);
      break;
    case 'paypal':
      console.log(`Requesting ${amount} from PayPal account ${method.email}`);
      break;
  }
}

// 使用例
const payment = createPaymentId('PMT-123456');
const customer = createCustomerId('CUST-789012');

processPayment(
  payment,
  customer,
  99.99,
  { type: 'credit_card', cardNumber: '4111111111111111', expiry: '12/24', securityCode: '123' }
);

まとめ

typescriptのタグ付きユニオン型とブランド型は、型安全性を大幅に向上させることがわかりました。

これらの型定義テクニックを適切に組み合わせることで、より堅牢で保守性の高いコードを書くことができ、tsやってる感がより一層増して面白いと感じました。

他にも面白そうなtsのtipがあれば自身のメモとしてもまとめていきたいと思います。

今回の記事が誰かのお役に立てれば幸いです。

NCDCエンジニアブログ

Discussion