はじめに
Discriminated Union は、複数の型を合併するユニオンの一種です。
しかし単なるユニオンではなく、「複数の型のうち現在どの型であるかが判別可能なユニオン」 を指します。
参考: 判別共用体 – F# | Microsoft Learn, 判別可能なユニオン型 (discriminated union) | サバイバル TypeScript
例えば User = メンバー or ゲスト
だとして、
-
User
がメンバー
のとき:メンバーの名前
を表示したい -
User
がゲスト
のとき:"ゲスト"
と表示したい
のように「今どの型か」に応じて処理を分けたい場合があると思います。
こうしたケースにおいて、入口では User
という一つの型として扱いながら、実処理としては メンバー
型、 ゲスト
型のどちらかであるかを判別し、それぞれの型として扱うことができるのが Discriminated Union です。
皆様は、上記のケースにおいてどのような型(クラス)定義をしますでしょうか?
私はしばしば以下のような定義をしてきました……。
public class User
{
public UserType UserType;
public string? Name;
}
メンバー
も ゲスト
も User
という一つの型で共通的に表現し、UserType
でメンバーかゲストかどうか判断するという考え方です。
この定義には明らかな課題があります。「 ゲスト
に Name
は不要なのに設定できてしまう」といった 「ビジネスルール上ありえない不整合な状態」を表現できる定義 となっていることです。(詳細は次のセクションで説明します)
本記事では、上記の課題について詳細事例を紹介したうえで、 Discriminated Union の活用によって改善できることを解説していきたいと思います。
また C# では Discriminated Union が公式にサポートされているわけではなく、あくまで “再現” する形となるため、複数の再現方法や、再現における課題感も合わせて紹介したいと思います。
本記事が少しでも C# での型安全な開発に役立てば幸いです!
想定読者
※あくまで筆者の想定なので、参考までに。
- 普段 C# を使って開発されている方
- より型安全なコードを書きたいと考えている方
- コンパイラの力を借りて、不正な状態や意図しない動作を未然に防ぎたいと思っている方
- bool フラグや null チェックの多用に課題を感じている方
- 序文で挙げた例のように、プロパティの組み合わせで状態を表現しようとして、複雑さや潜在的なバグに悩んだ経験のある方
- 他の言語の Discriminated Union を知っている方
- F# や TypeScript などの言語で Discriminated Union の便利さを知っており、C# でも同様のアプローチを取り入れたいと考えている方
また本記事内のサンプルコードは、主に net 9.0
, C# 13
で動作確認しています。特に最新機能を多用しているわけではありませんが、下記機能(本記事で使用している C# 8
以降の機能)がわかっていると読み進めやすいかと思います。
課題事例: 3つのユーザー種別を想定した User クラス
では、本題に入ります。
冒頭で簡易的な User
クラスのサンプルを示しましたが、課題がわかりやすいように、ここからはよりパターンの多い User
クラスを使って解説していきます。
システムに以下 3 種類のユーザーが存在するとします。
-
Admin: 管理者。全てのプロジェクトにアクセス可能。
- 必須プロパティ:
Email
,Name
- 不要プロパティ:
AccessibleProjectIds
(常に全アクセス権を持つため)
- 必須プロパティ:
-
Member: 通常ユーザー。特定のプロジェクトにのみアクセス可能。
- 必須プロパティ:
Email
,Name
,AccessibleProjectIds
- 必須プロパティ:
-
Guest: ゲストユーザー。招待された 1 つのプロジェクトにのみアクセス可能。
- 必須プロパティ:
AccessibleProjectId
(単一 ID) - 不要プロパティ:
Email
,Name
(匿名アクセスを想定)
- 必須プロパティ:
これらのユーザー種別を 1 つのクラスで表現した定義が、以下の User
クラスです。
public class User
{
public required UserType Type { get; init; }
public string? Email { get; init; }
public string? Name { get; init; }
public int[]? AccessibleProjectIds { get; init; }
}
この User クラスを使ったサンプルコード全体(以後 課題コード と呼びます)も合わせて提示します。
課題コード
Sample.Run();
public enum UserType
{
None = 0,
Admin,
Member,
Guest,
}
public class User
{
public required UserType Type { get; init; }
public string? Email { get; init; }
public string? Name { get; init; }
public int[]? AccessibleProjectIds { get; init; }
}
public static class Sample
{
public static void Run()
{
var admin = new User
{
Type = UserType.Admin,
Email = "admin@example.com",
Name = "Admin 太郎",
AccessibleProjectIds = null,
};
var member = new User
{
Type = UserType.Member,
Email = "member@example.com",
Name = "Member 次郎",
AccessibleProjectIds = [1, 2, 3],
};
var guest = new User
{
Type = UserType.Guest,
Email = null,
Name = null,
AccessibleProjectIds = [6],
};
Console.WriteLine(GetDescription(admin));
Console.WriteLine(GetDescription(member));
Console.WriteLine(GetDescription(guest));
}
public static string GetDescription(User user) => user.Type switch
{
UserType.Admin => $"Admin | {user.Name} ({user.Email})",
UserType.Member => $"Member | {user.Name} ({user.Email}), Projects: [{string.Join(", ", user.AccessibleProjectIds ?? Array.Emptyint>())}]",
UserType.Guest => $"Guest | Project: {user.AccessibleProjectIds?[0]}",
_ => throw new ArgumentException("Invalid user type"),
};
}
Admin | Admin 太郎 (admin@example.com)
Member | Member 次郎 (member@example.com), Projects: [1, 2, 3]
Guest | Project: 6
この User
クラスには、次のような問題点があります。
順に解説していきます。
課題 1: ありえない User インスタンスを作成できてしまう
「Member なのに Email
が null
」「Guest なのに Email
が設定されている」 といった、ビジネスルール上ありえない状態のインスタンスを作成できてしまいます。
var member = new User
{
Type = UserType.Member,
Email = null,
Name = "Member 次郎",
AccessibleProjectIds = [1, 2, 3],
};
var guest = new User
{
Type = UserType.Guest,
Email = "guest@example.com",
Name = null,
AccessibleProjectIds = [6],
};
課題 2: ありえないプロパティ状態を考慮しなければならない
前述の通り「Member なのに Email
が null
」「Guest なのに Email
が設定されている」といったことが定義上許容されています。
そのため余計に null チェックが必要だったり、使うべきでないプロパティを使えてしまうという課題が生じます。
public static string GetDescription(User user) => user.Type switch
{
UserType.Admin => $"Admin | {user.Name} ({user.Email})",
UserType.Member => $"Member | {user.Name} ({user.Email}), Projects: [{string.Join(", ", user.AccessibleProjectIds ?? Array.Emptyint>())}]",
UserType.Guest => $"Guest | {user.Name} ({user.Email}), Project: {user.AccessibleProjectIds?[0]}",
_ => throw new ArgumentException("Invalid user type"),
};
改善: Discriminated Union で型安全性を高める
続いて、ここまで紹介してきた課題を Discriminated Union によって改善していきたいと思います。
Discriminated Union を活用し、改善した定義が次のとおりです。
public abstract record User;
public record Admin(string Email, string Name) : User;
public record Member(string Email, string Name, int[] AccessibleProjectIds) : User;
public record Guest(int AccessibleProjectId) : User;
改善したコード(以後 改善コード と呼びます)の全体も合わせて提示します。
改善コード
Sample.Run();
public abstract record User;
public record Admin(string Email, string Name) : User;
public record Member(string Email, string Name, int[] AccessibleProjectIds) : User;
public record Guest(int AccessibleProjectId) : User;
public static class Sample
{
public static void Run()
{
var admin = new Admin(
Email: "admin@example.com",
Name: "Admin 太郎");
var member = new Member(
Email: "member@example.com",
Name: "Member 次郎",
AccessibleProjectIds: [1, 2, 3]);
var guest = new Guest(
AccessibleProjectId: 6);
Console.WriteLine(GetDescription(admin));
Console.WriteLine(GetDescription(member));
Console.WriteLine(GetDescription(guest));
}
public static string GetDescription(User user) => user switch
{
Admin admin => $"Admin | {admin.Name} ({admin.Email})",
Member member => $"Member | {member.Name} ({member.Email}), Projects: [{string.Join(", ", member.AccessibleProjectIds)}]",
Guest guest => $"Guest | Project: {guest.AccessibleProjectId}",
_ => throw new ArgumentException("Invalid user type"),
};
}
Discriminated Union の再現方法(record)
まず初めに、 Discriminated Union の再現方法について解説します。(後述で他の再現方法も紹介します)
第一に、各ユーザー種別を別々のクラスとして定義します。これにより 必要なプロパティのみを持った型定義 を実現します。
public record Admin(string Email, string Name);
public record Member(string Email, string Name, int[] AccessibleProjectIds);
public record Guest(int AccessibleProjectId);
そのうえで、 Admin
Member
Guest
を共通的な型として扱えるよう(=ユニオンを再現するため)、共通の基底型 User
を定義し各クラスに継承させます。
public abstract record User;
public record Admin(string Email, string Name) : User;
public record Member(string Email, string Name, int[] AccessibleProjectIds) : User;
public record Guest(int AccessibleProjectId) : User;
以上で完成です。
この定義により User = Admin or Member or Guest
という Discriminated Union が再現できています。
改善 1: ビジネスルール上ありえない User インスタンスは作成できない
課題コードでは、「Member なのに Email
が null
」「Guest なのに Email
が設定されている」といった、ビジネスルール上ありえない状態のインスタンスを作成できてしまう問題がありました。
一方改善コードでは、 Admin, Member, Guest それぞれが自分に必要なプロパティしか持っていないため、ビジネスルール上ありえないインスタンスを作成することができません。
var member = new Member(
AccessibleProjectIds: [1, 2, 3]);
var guest = new Guest(
Email: "guest@example.com",
Name: "Guest 三郎",
AccessibleProjectId: 6);
改善 2: パターンマッチングで必要なプロパティにのみアクセス
課題コードでは、「ビジネスルール上ありえないプロパティ状態を考慮しなければならない」という問題がありました。
具体的には以下のような問題です。
- Member の場合に
user.AccessibleProjectIds
の null チェックが必要 - Guest の場合に
user.Name
user.Email
を指定できてしまう(ので、指定しないように気をつける必要がある)
改善コードでは、Admin
, Member
, Guest
という型でパターンマッチングさせることにより、上記の問題を解消することができます。
public static string GetDescription(User user) => user switch
{
Admin admin => $"Admin | {admin.Name} ({admin.Email})",
Member member => $"Member | {member.Name} ({member.Email}), Projects: [{string.Join(", ", member.AccessibleProjectIds)}]",
Guest guest => $"Guest | {guest.Name} ({guest.Email}), Project: {guest.AccessibleProjectId}",
_ => throw new ArgumentException("Invalid user type"),
};
改善のまとめ
Discriminated Union の活用により、当初のコードが抱えていた以下の課題を解決し、型安全性を向上させることができました。
-
不正な状態の防止: 各ユーザー種別(Admin, Member, Guest)を個別の型として定義することで、「Member なのに
Email
がnull
」といったビジネスルール上ありえないインスタンスの作成をコンパイルエラーで防ぎます。 - 安全なプロパティアクセス: パターンマッチングを用いることで、各ケースで実際に存在するプロパティにのみ安全にアクセスできるようになり、不要な null チェックや、存在しないプロパティへのアクセスによる実行時エラーを防ぎます。
この改善の根底にあるのは、「ありえない状態を表現しない」 という方針です。課題コードでは、User
クラスに全てのプロパティを集約した結果、不整合な状態を許容してしまっていました。
改善コードでは、まず各ユーザー種別を適切に分離して定義し、それぞれの型が必要な情報だけを持つようにしました。そして、これらの型を Discriminated Union としてまとめる ことで、共通の User
型として扱いながらも、それぞれのケースに応じた安全な処理を実現しています。
補足: record による Discriminated Union 再現の課題
さて少し話が変わりますが、上記で紹介した Discriminated Union の再現方法(record) のコードには一つ課題があります。
それは GetDescription
メソッドで使用している switch 式について 網羅性チェックが不完全 である、という課題です。
例えば、新たなユーザー種別 SuperAdmin
が追加された場合を考えてみましょう。本来は GetDescription
の switch 式で網羅性チェックによる警告やエラーが出てほしいところですが、何も出ません。
理由は _
パターンがすべてを網羅してしまっているためです。
しかし現状の C# の仕様上、 _
を指定するほかないようです。(参考: https://github.com/dotnet/csharplang/issues/2228)
つまり、上記で紹介した record
による Discriminated Union 再現では、 switch 式の追加し忘れによる実行時エラーが起こる危険性があります。
個人的に色々と調べたり ChatGPT 等の AI ツールを活用して試行錯誤していましたが、解決には至りませんでした。(何かよい解決法をご存じの方がいればお教え頂けますと幸いです 🙇)
もし record
による Discriminated Union 再現のご活用を検討されている方がいれば、この点には十分留意したうえでご活用頂ければと存じます。(少なくとも課題コードで示した User
よりは不整合が起こりにくく安全なコードだと思いますので、網羅性チェックが十分でないとはいえ、活用の価値があるのではないかなと個人的には考えております)
また外部ライブラリを導入できる場合に限りますが、次のセクションで紹介する OneOf
を使った Discriminated Union の再現 では網羅性チェックの問題も解消しますので、ぜひ気になる方はご覧いただければと存じます。
その他の再現方法
ここまで record
を使った Discriminated Union の再現方法を紹介してきました。
以降では、さらに 2 つの Discriminated Union 再現方法を紹介したいと思います。
1. 抽象クラス (C# 8.0 以前)
C# 8.0 以前のバージョンでは record
ではなく class
を使って同様に実装することが可能です。
public abstract class User { }
public class Admin : User
{
public string Email { get; }
public string Name { get; }
public Admin(string email, string name)
{
Email = email;
Name = name;
}
}
public class Member : User
{
public string Email { get; }
public string Name { get; }
public int[] AccessibleProjectIds { get; }
public Member(string email, string name, int[] accessibleProjectIds)
{
Email = email;
Name = name;
AccessibleProjectIds = accessibleProjectIds;
}
}
public class Guest : User
{
public int AccessibleProjectId { get; }
public Guest(int accessibleProjectId)
{
AccessibleProjectId = accessibleProjectId;
}
}
C# 8.0 以前では、まず record
が使えないので class
を使う必要があります。
また required
修飾子が使えないため、コンストラクタで必須プロパティを初期化する必要があります。(コンストラクタを使わなくともプロパティとしては定義できますが、その場合プロパティを nullable にせざるを得ないので型安全性が低下します)
プロパティのアクセサについては、 record
での実装時と同様、初期化時以外に変更できないよう get
のみを指定して、コンストラクタで初期化する形式を取っています。
record
での再現方法と比べると記述量が多く、上記のように注意した書き方をしなければならないため、基本的に record
が使える環境下では Discriminated Union の再現方法(record) に記載の方法がおすすめです。
2. ライブラリ OneOf
Discriminated Union を record
および class
で再現するには継承を使う必要がありましたが、ライブラリ OneOf を使うことで、宣言的に型の OR を表現できるようになり、より直感的に Discriminated Union を再現できます。
OneOf
の導入方法、使い方の簡易解説
以下のコマンドを実行し、 OneOf をプロジェクトに導入します。
dotnet add package OneOf
dotnet add package OneOf.SourceGenerator
OneOf
を使うことで、複数の型を組み合わせた型を表現することができます。
例えば、以下のように string
と int
のユニオン型を引数で受け取る関数を定義することができます。
using OneOf;
Sample.Run();
public static class Sample
{
public static void Run()
{
var result1 = GetStringOrNumber("文字列です!");
Console.WriteLine(result1);
var result2 = GetStringOrNumber(42);
Console.WriteLine(result2);
}
public static string GetStringOrNumber(OneOfstring, int> stringOrNumber) => stringOrNumber.Match(
str => $"String value: {str}",
num => $"Number value: {num}"
);
}
String value: 文字列です!
Number value: 42
また OneOfBase
を使うことで、 OR で組み合わせた型をクラスとして定義しておくことが可能となります。
※ OneOfBase
を使う場合、各型に対する型変換のロジック等を自分で実装する必要があり、やや手間がかかります。(GitHub リポジトリ参照)。そこで OneOf.SourceGenerator
パッケージを導入し [GenerateOneOf]
属性を使うことで、 Source Generator によってこれらの処理が自動生成され、手軽に扱えるようになります。
using OneOf;
namespace OneOfSample;
[GenerateOneOf]
public partial class StringOrNumber : OneOfBasestring, int> { }
public static class Sample
{
public static void Run()
{
var str = "文字列です!";
var num = 42;
Console.WriteLine(GetStringOrNumber(str));
Console.WriteLine(GetStringOrNumber(num));
}
public static string GetStringOrNumber(StringOrNumber stringOrNumber) => stringOrNumber.Match(
str => $"String value: {str}",
num => $"Number value: {num}"
);
}
簡易的な OneOf
の解説は以上です。
詳しく知りたい方は、下記 OneOf
の GitHub リポジトリをご参照ください。
OneOf
を使った Discriminated Union の再現
本題に戻りまして、最後に OneOf
を使った Discriminated Union の再現方法を紹介します。
using OneOf;
SampleApplication.Sample.Run();
namespace SampleApplication
{
public record Admin(string Email, string Name);
public record Member(string Email, string Name, int[] AccessibleProjectIds);
public record Guest(int AccessibleProjectId);
[GenerateOneOf]
public partial class User : OneOfBaseAdmin, Member, Guest> { }
public static class Sample
{
public static void Run()
{
var admin = new Admin(
Email: "admin@example.com",
Name: "Admin 太郎");
var member = new Member(
Email: "member@example.com",
Name: "Member 次郎",
AccessibleProjectIds: [1, 2, 3]);
var guest = new Guest(
AccessibleProjectId: 6);
Console.WriteLine(GetDescription(admin));
Console.WriteLine(GetDescription(member));
Console.WriteLine(GetDescription(guest));
}
public static string GetDescription(User user) => user.Match(
admin => $"Admin: {admin.Name} ({admin.Email})",
member => $"Member: {member.Name} ({member.Email}), Projects: [{string.Join(", ", member.AccessibleProjectIds)}]",
guest => $"Guest: {guest.AccessibleProjectId}"
);
}
}
Admin: Admin 太郎 (admin@example.com)
Member: Member 次郎 (member@example.com), Projects: [1, 2, 3]
Guest: 6
record
や class
を使った Discriminated Union の再現方法と比べて、継承を使わずに OneOfBase
と書くことで、 ユニオンらしい型を表現できるのが特徴です。
また switch
式の代わりに Match
メソッドを使う点に、大きなメリットがあります。
補足: record による Discriminated Union 再現の課題 に記載した通り、 record
による Discriminated Union 再現のコードでは網羅性チェックに課題が残りました。
OneOf
方式では Match
メソッドによって、全型の分岐(実際には対象の型を引数に取る関数)を過不足なく書くことが強制されます。
これによって網羅性が担保され、より安全なコードになるという大きなメリットがあります。
以上を踏まえ、もし外部ライブラリを使える環境下であれば、 OneOf
方式が最善なのかなと個人的には考えております。
おわりに
本記事では、C# で Discriminated Union を再現する方法と、それによって型安全性が高まることを解説しました。
私が本記事を書こうと思った(というか Discriminated Union に興味を持った)最初のきっかけは、TypeScript の Discriminated Union と Haskell の代数的データ型 を読んだことでした。
Haskell も関数型プログラミングも初学者レベルの自分には、正直ほとんど理解することができなかったのですが、ひとまず冒頭に書いている 「ありえない状態を表現しない」 については目指したいなと思った次第でした。
そこで実際に弊社 Thinkings で sonar ATS バックエンド開発に使用している C# でも「ありえない状態を表現しない」という方針を適用することが可能なのか、適用してメリットがあるのか、と気になって色々と試してみたことが本記事を書くに至った経緯です。
試行錯誤の結果、C# での Discriminated Union 再現、および「ありえない状態を表現しない」という方針の実現ができました。
実際のプロダクト開発でも「ありえない状態を表現しない」という方針を心がけ、より保守性・可読性の高いコードを目指していきたいと思いました!
最後に補足ですが、本記事タイトルを「Discriminated Union を “再現”」という表現にした(”実現” にしなかった)理由は、C# の現在の仕様では、TypeScript や F# などの Discriminated Union を完全に同じ形で “実現” できるわけではないためです。
特に record
や class
を用いた方法では、switch
式の網羅性チェックに課題が残ります (補足: record による Discriminated Union 再現の課題 参照)。
C# 言語設計チームでも、以前から Discriminated Union の導入について議論されているようなので (GitHub Issue #113 など)、将来的に言語レベルでサポートされることを期待しています。
以上、最後までお読みくださりありがとうございました! 本記事が C# を利用する皆様にとって少しでも参考になれば幸いです。
参考資料
- Discriminated Union 全般
- OneOf ライブラリ
- C# 言語仕様の議論 (Discriminated Unions)
Views: 0