Branded TypesとDiscriminated Unionの違い
Branded TypesとDiscriminated Unionをご存知でしょうか?
これらはTypeScriptでよく使われる型テクニックですが、やっていることが微妙に似ていて混同されることがあります。
本記事では、これら2つの違いについて簡潔に説明します。
Branded Typesとは
Branded Typesとは、Structural Typing(構造的型付け)を採用している言語でNominal Typing(名前的型付け)を実現するためのテクニックです。
たとえば、以下のようなTypeScriptの型があったとします。
type User = {
id: number;
name: string;
};
type Product = {
id: number;
name: string;
};
UserとProductは、どちらもidとnameをプロパティに持つオブジェクト型です。このとき、Structural Typingの言語ではUser型の変数にProduct型のオブジェクトを代入できてしまいます。これでは、User型のオブジェクトに誤ってProduct型のオブジェクトを代入してしまう危険があります。
const user: User = {
id: 1,
name: "田中太郎",
};
const product: Product = user; // 型エラーは発生しない
このように、型の同一性を構造のみで判断する型付けをStructural Typingといいます。
それに対して、型の同一性を名前で判断する型付けをNominal Typingといいます。
Nominal Typingを採用しているJavaでは、同じ構造を持つクラスであっても、型名が異なれば互換性がありません。
class User {
int id;
String name;
User(int id, String name) {
this.id = id;
this.name = name;
}
}
class Product {
int id;
String name;
Product(int id, String name) {
this.id = id;
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
User user = new User(1, "田中太郎");
Product product = user; // コンパイルエラー: incompatible types
}
}
UserとProductは全く同じフィールドを持っていますが、Javaでは型名が異なるため代入できません。このように、型の互換性を名前で判断するのがNominal Typingです。
さて、Branded Typesとは、Structural Typingを採用している言語でNominal Typingを実現するためのテクニックだといいました。これは一体どういうことでしょうか?
Structural Typingで型名が考慮されないのならば、型名を構造に含めてしまえばいいのです。
type User = {
id: number;
name: string;
__brand: "User";
};
type Product = {
id: number;
name: string;
__brand: "Product";
};
これにより、UserとProductは異なる構造を持つ型になりました。そのため、以下のコードは型エラーになります。
const user: User = {
id: 1,
name: "田中太郎",
} as User;
const product: Product = user; // 型エラーが発生
なお、stringやnumberのようなプリミティブな型でも交差型を用いることでBranded Typesを表現できます。
type UserId = number & { __brand: "UserId" };
type ProductId = number & { __brand: "ProductId" };
これで、構造は同じでも意味的に異なる型同士の誤代入を防止できるようになりました。
Discriminated Unionとは
Discriminated Union(判別可能なUnion型)とは、共通の判別用プロパティ(タグ)を持つ複数の型をUnionでまとめた型です。タグの値を見ることで、どの型なのかを絞り込めます。
たとえば、ステータスを持つタスクを表す以下のような型を考えてみましょう。ここではタスクのステータスとしてtodo、pending、doneの3つがあるとします。
type TodoTask = {
status: "todo";
title: string;
};
type PendingTask = {
status: "pending";
title: string;
startedAt: Date;
};
type DoneTask = {
status: "done";
title: string;
completedAt: Date;
};
type Task = TodoTask | PendingTask | DoneTask;
ここで、statusプロパティがタグ(discriminant)になります。Task型の値はstatusの値を見るだけで、どのステータスのタスクなのかを判別できます。
では、Discriminated Unionを使用するメリットは何でしょうか? それは、不正な状態を型レベルで作りだせなくすることです。
実は、Discriminated Unionを使用せず、以下のような型定義でも動作的には問題がありません。
type Task = {
status: "todo" | "pending" | "done";
title: string;
startedAt?: Date;
completedAt?: Date;
};
しかし、本来startedAtはstatusがpendingのときのみ存在する値であり、同様にcompletedAtもstatusがdoneのときのみ存在する値です。
上記の型定義では、たとえばstartedAtとcompletedAtが同時に存在するような不正な状態も作りだすことができてしまいます。
Discriminated Unionは、このような不正な状態を作りだすことを型レベルで防止できます。
また、以下のようにSwitch文での型の絞り込みも厳密に行うことができます。
function describe(task: Task): string {
switch (task.status) {
case "todo": {
return `${task.title} は未着手です`;
}
case "pending": {
return `${task.title} は ${task.startedAt.toLocaleDateString()} から作業中です`;
}
case "done": {
return `${task.title} は完了しました`;
}
default: {
return task satisfies never;
}
}
}
Switch文でタグを分岐させると、それぞれのcaseの中でTypeScriptが自動的に型を絞り込んでくれます。たとえばcase "pending"の内側ではtaskはPendingTask型として扱われ、task.startedAtへ安全にアクセスできます。同様にcase "done"の内側ではDoneTask型としてtask.completedAtを参照できます。これをType Narrowing(型の絞り込み)といいます。
また、default節のtask satisfies neverは網羅性チェックのためのイディオムです。すべてのcaseを処理し終えるとtaskはnever型に絞り込まれるため、satisfies neverが成立します。一方、新しいステータスをTaskへ追加してcaseを書き忘れると、taskがneverにならず型エラーが発生します。これにより、ステータスの追加漏れを静的に検出できます。
さらに、ステータスの遷移を型で表現できます。引数と戻り値に具体的なステータスの型を指定すれば、「どの状態からどの状態へ遷移できるか」を型として表せます。
const startTask = (task: TodoTask): PendingTask => ({
status: "pending",
title: task.title,
startedAt: new Date(),
});
const completeTask = (task: PendingTask): DoneTask => ({
status: "done",
title: task.title,
completedAt: new Date(),
});
startTaskはTodoTaskしか受け取れないため、すでに完了したDoneTaskを誤って開始しようとすると型エラーになります。このように、不正な状態遷移そのものを型レベルで防止できます。
この考え方は、Parse, don't validateとして知られる設計思想とも共通しています。
「検証(validate)」とは、値が条件を満たすかをチェックするだけの操作です。その結果は型に残らないため、その後は正しさを前提にして書くしかなく、同じ確認をあちこちで繰り返すことになります。
一方「解析(parse)」では、不正な状態を表現できない型にデータを変換します。正しさが型に含まれているぶん、後から確かめ直す必要がありません。フラットなTask型を都度チェックして回るのに対して、Discriminated Unionは成立する状態だけを型で表します。これが、Parse, don't validate のやり方です。
2つの違い
ここまで見てきたように、Branded TypesとDiscriminated Unionはどちらも「型を区別するためのプロパティを構造に付与する」点が似ています。
- Branded Typesの
__brand: "User" - Discriminated Unionの
status: "todo"
両者ともリテラル型のタグを持っているため、一見すると同じテクニックのように見えます。しかし、その目的は全く異なります。
Branded Typesは、構造が同じ型同士を区別するためのものです。Brandは実行時には存在しない、コンパイル時だけの目印(phantom)です。Brandプロパティに実際の値を代入することはなく、あくまで型の同一性を分けることだけが目的です。
一方Discriminated Unionは、複数の型を1つのUnionにまとめ、実行時にタグの値で分岐・絞り込みを行うためのものです。タグは実行時に実際に存在する値であり、Switch文やif文で参照されます。
両者の違いを表にまとめると、以下のようになります。
- 目的
- Branded Types: 構造が同じ型同士を区別する
- Discriminated Union: 複数の型を1つにまとめて絞り込む
- タグの値
- Branded Types: 実行時には存在しない(phantom)
- Discriminated Union: 実行時に実際に存在する
- 主な使い道
- Branded Types: 誤代入の防止
- Discriminated Union: Type Narrowingや不正な状態の作成防止
Branded Typesは「構造的には同じ型を別物として扱いたい」とき、Discriminated Unionは「複数の型をまとめて扱い、状態ごとに絞り込みたい」ときに使います。似たような見た目でも、解決したい問題が異なることを押さえておきましょう。
補足: unique symbolによるBrand
ここまで、Brandには__brand: "User"のような文字列リテラルを使ってきました。しかし実際にはunique symbolでBrandを定義するケースが多いです。
declare const UserBrand: unique symbol;
declare const ProductBrand: unique symbol;
type User = {
id: number;
name: string;
[UserBrand]: unknown;
};
type Product = {
id: number;
name: string;
[ProductBrand]: unknown;
};
文字列リテラルのBrandは、別々の型でうっかり同じキーと値を使うと衝突してしまったり、__brandプロパティがIDEの補完に表示されてしまったりといった問題があります。
unique symbolを使えば宣言ごとに必ず一意なキーが生成され、補完へ現れることもありません。そのため、より安全にBranded Typesを表現できます。