金曜日, 5月 9, 2025
No menu items!
ホームニューステックニュースC# で Discriminated Union を再現し、型安全性を高める

C# で Discriminated Union を再現し、型安全性を高める


はじめに

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 なのに Emailnull」「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 なのに Emailnull」「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 なのに Emailnull」「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 の活用により、当初のコードが抱えていた以下の課題を解決し、型安全性を向上させることができました。

  1. 不正な状態の防止: 各ユーザー種別(Admin, Member, Guest)を個別の型として定義することで、「Member なのに Emailnull」といったビジネスルール上ありえないインスタンスの作成をコンパイルエラーで防ぎます。
  2. 安全なプロパティアクセス: パターンマッチングを用いることで、各ケースで実際に存在するプロパティにのみ安全にアクセスできるようになり、不要な 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 を使うことで、複数の型を組み合わせた型を表現することができます。

例えば、以下のように stringint のユニオン型を引数で受け取る関数を定義することができます。

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 リポジトリをご参照ください。

https://github.com/mcintyre321/OneOf

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

recordclass を使った 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 を完全に同じ形で “実現” できるわけではないためです。

特に recordclass を用いた方法では、switch 式の網羅性チェックに課題が残ります (補足: record による Discriminated Union 再現の課題 参照)。

C# 言語設計チームでも、以前から Discriminated Union の導入について議論されているようなので (GitHub Issue #113 など)、将来的に言語レベルでサポートされることを期待しています。

以上、最後までお読みくださりありがとうございました! 本記事が C# を利用する皆様にとって少しでも参考になれば幸いです。

参考資料

  • Discriminated Union 全般
  • OneOf ライブラリ
  • C# 言語仕様の議論 (Discriminated Unions)

フラッグシティパートナーズ海外不動産投資セミナー 【DMM FX】入金

Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -

Most Popular

Recent Comments