C# Union Type入門|ない理由と代替実装をrecord・OneOf・パターンマッチで解説
はじめに
C#で「Union Typeを使いたい」と感じる場面は少なくありません。たとえば、あるメソッドが「成功結果」または「エラー結果」のどちらかを返す場合、APIのレスポンスが「正常」「バリデーションエラー」「認証エラー」のいずれかになる場合、あるいはドメインの状態を「下書き」「公開済み」「却下済み」のように型で安全に表現したい場合です。
Union Typeは、こうした「複数の型のうち、どれか1つだけを取りうる値」を表現するための仕組みです。TypeScript、F#、Rustなどではよく使われる考え方ですが、C#では長い間、標準の安定版機能としては直接的なUnion Typeがありませんでした。
なお、2026年6月時点では、C# 15と.NET 11 Preview 2でunionキーワードによるUnion Typeがプレビュー機能として紹介されています。ただし、既存の多くのC#プロジェクトではC# 14以前、または安定版の.NETを前提にするケースが多いため、実務ではまだrecord、抽象クラス、パターンマッチ、OneOfなどによる代替実装が重要です。C# 15のUnion Typeは.NET 11 Preview 2から利用可能とされており、プレビュー環境ではLangVersionにpreviewを指定する必要があります。Microsoft for Developers
この記事では、C#におけるUnion Typeの考え方、ないと言われてきた理由、recordやOneOfを使った代替実装、パターンマッチとの組み合わせ、Result型やAPIレスポンスでの実践例まで解説します。
1. C# Union Type入門|まず何を解決する仕組みなのか
Union Typeを理解するうえで重要なのは、「値が取りうる形を型で制限する」という点です。
C#では、通常1つの変数には1つの型を指定します。
C#string name = "Taro";
int age = 30;
しかし実務では、「この戻り値はUserかもしれないし、NotFoundかもしれない」「この処理結果はSuccessかValidationErrorかUnauthorizedのどれか」といったケースがよくあります。
このような場合に、objectで何でも返したり、nullで失敗を表したり、例外で通常の分岐を表現したりすると、呼び出し側のコードが不安定になりやすくなります。
Union Typeは、その曖昧さを減らすための仕組みです。
1-1. Union Typeとは「複数の型のどれか1つ」を表す型
Union Typeとは、簡単に言えば「A型またはB型、またはC型のどれか1つ」を表す型です。
たとえば、次のような戻り値を考えます。
C#User
NotFound
ValidationError
この3つのうち、どれか1つだけを返す型を作れれば、呼び出し側は「成功したのか」「見つからなかったのか」「入力エラーなのか」を型として扱えます。
イメージとしては、次のような型です。
C#UserResult = User | NotFound | ValidationError
C# 14以前の安定版ではこのような構文を直接書くことはできません。そのため、abstract record、recordの継承、OneOfライブラリ、パターンマッチなどを組み合わせてUnion Type風に実装します。
1-2. TypeScript・F#・RustなどにあるUnion Typeとの違い
TypeScriptでは、次のようにUnion Typeを簡単に書けます。
TypeScripttype Result = User | ErrorResponse;
F#には判別共用体があります。F#の判別共用体は、値が複数の名前付きケースのいずれかになれる仕組みで、各ケースは異なる値や型を持つことができます。Microsoft Learnでも、F#の判別共用体は「値が複数の名前付きケースのいずれかになれる」機能として説明されています。Microsoft Learn
Rustでは、enumが非常に強力で、各ケースにデータを持たせることができます。
Rustenum Result<T, E> {
Ok(T),
Err(E),
}
一方、C#のenumは基本的に名前付き定数の集合であり、RustのenumやF#の判別共用体のように、ケースごとに異なるデータを持たせる用途には向いていません。
C#でもC# 15 Previewではunionキーワードが導入され始めていますが、現時点で多くの実務コードではC# 14以前の書き方を前提にする必要があります。C# 14の公式ドキュメントでは、C# 14が.NET 10でサポートされる最新の安定リリースとして説明されています。Microsoft Learn
1-3. C#でUnion Typeを使いたくなる代表的な場面
C#でUnion Typeを使いたくなる代表的な場面は、次のようなケースです。
C#// ユーザー取得の結果
User
NotFound
DatabaseError
// 注文処理の結果
OrderCompleted
PaymentFailed
OutOfStock
// APIレスポンス
OkResponse
ValidationErrorResponse
UnauthorizedResponse
これらはすべて、「1つの処理結果が、複数の意味を持つ型のどれかになる」例です。
boolだけでは詳細が足りません。nullだけでは理由が分かりません。例外だけにすると、通常あり得る分岐まで例外処理になってしまいます。
Union Type風の設計を使うと、処理結果のパターンを型として明示できます。
1-4. null・例外・object・継承だけでは表現しにくい理由
C#では、Union Typeがない場合に次のような代替が使われがちです。
C#object GetUserResult(int id)
{
if (id <= 0) return "Invalid id";
if (id == 404) return null;
return new User(id, "Taro");
}
このコードは一見動きますが、呼び出し側は戻り値が何なのかを安全に判断できません。
C#var result = GetUserResult(1);
if (result is User user)
{
Console.WriteLine(user.Name);
}
else if (result is string message)
{
Console.WriteLine(message);
}
else if (result is null)
{
Console.WriteLine("Not found");
}
問題は、objectが何でも受け入れてしまうことです。戻り値としてintやDateTimeを返してもコンパイルが通ってしまいます。
nullも同様です。nullは「見つからなかった」のか、「未設定」なのか、「エラー」なのか、文脈を読まなければ分かりません。
例外は本来、予期しない異常や処理を継続できない状況を表すのに向いています。ユーザーが見つからない、入力値が不正、在庫がないといった業務上の通常分岐をすべて例外で表すと、制御フローが追いにくくなります。
2. C#にUnion Typeがない理由
C#では長い間、Union Typeが言語機能として標準搭載されていませんでした。その背景には、C#がオブジェクト指向言語として発展してきたこと、既存のクラス・インターフェース・ジェネリック・パターンマッチとの整合性を重視してきたことがあります。
2-1. 現時点のC#には言語機能としてのUnion Typeはない
厳密に言うと、C# 15 Previewではunionキーワードが導入され始めています。Microsoftの.NET Blogでは、C# 15が.NET 11 Preview 2からunionキーワードを導入し、値が固定された型集合のうち正確に1つであることを表現できると説明されています。Microsoft for Developers
ただし、C# 14以前の安定版を前提にする場合、TypeScriptのA | BやF#の判別共用体のようなUnion Type構文はありません。そのため、実務で「C#にはUnion Typeがない」と言う場合、多くは「安定版のC#で広く使える標準機能として、長らくUnion Typeがなかった」という意味になります。
この記事で扱う代替実装も、主にC# 14以前、またはC# 15 Previewをまだ採用しないプロジェクトを想定しています。
2-2. C#がクラス・インターフェース・ジェネリック中心に進化してきた背景
C#は、Javaに近いオブジェクト指向言語として出発し、クラス、インターフェース、継承、ジェネリック、LINQ、非同期処理、パターンマッチ、recordなどを段階的に取り入れてきました。
そのため、複数のケースを表すときには、従来は次のような設計がよく使われました。
C#public abstract class PaymentResult
{
}
public sealed class PaymentSucceeded : PaymentResult
{
}
public sealed class PaymentFailed : PaymentResult
{
}
この設計はC#らしく、既存のオブジェクト指向設計とも相性が良いです。
一方で、これは「閉じた型集合」を完全に表現するものではありません。abstract classを使っても、設計次第では別の派生クラスが追加される可能性があります。インターフェースの場合は、さらに多くの型が実装できてしまいます。
Union Typeで欲しいのは、「この型はA、B、Cのどれかであり、それ以外はあり得ない」という制約です。この閉じた選択肢をC#で自然に表現することが難しかったのです。
2-3. 判別共用体に近い機能が標準搭載されていない理由
判別共用体に近い機能を標準搭載するには、単に新しい構文を追加するだけでは足りません。
重要なのは、次のような点です。
C#// すべてのケースを処理しているか
// 新しいケースを追加したときに既存のswitchが警告されるか
// 既存の型とどう組み合わせるか
// ジェネリックやnullableとどう相互作用するか
// シリアライズやASP.NET Coreのモデルバインディングとどう扱うか
C#にはすでにクラス、struct、record、enum、interface、nullable reference types、pattern matchingなどがあります。Union Typeを追加するには、これらとの整合性が重要になります。
実際、C#の言語設計リポジトリでは、判別共用体やType Unionsに関する提案が長く議論されてきました。GitHub上のC#言語設計リポジトリでも、Discriminated Unionsの提案が扱われています。GitHub
2-4. 今後C#にUnion Typeが追加される可能性はあるのか
可能性はあります。むしろ、C# 15 PreviewではすでにUnion Typeが導入され始めています。
ただし、プレビュー機能は仕様や使い勝手が変更される可能性があります。また、企業システムや長期運用プロジェクトでは、プレビュー版の言語機能をすぐに採用できないことも多いです。
そのため、当面は次のように考えるのが現実的です。
C#// C# 15 Previewを試せる環境
unionキーワードを検証する
// C# 14以前や安定版を使う実務環境
record、OneOf、パターンマッチで代替する
Union Typeが将来的に安定して使えるようになっても、Result型、recordによる状態表現、パターンマッチの考え方は引き続き重要です。
3. C#でUnion Typeが必要になるユースケース
C#でUnion Type風の設計が役立つのは、戻り値の意味が複数あるときです。特に、成功と失敗、APIレスポンス、状態遷移、エラー処理、null回避の場面で効果があります。
3-1. 成功・失敗を戻り値で表したい場合
たとえば、ユーザー登録処理を考えます。
C#public User Register(string email, string password)
{
if (string.IsNullOrWhiteSpace(email))
{
throw new ArgumentException("Email is required.");
}
return new User(email);
}
この実装では、入力エラーが例外で表されています。しかし、入力エラーは予期しない異常というより、通常起こり得る業務上の分岐です。
Union Type風に考えるなら、戻り値は次のどちらかです。
C#RegisterSuccess
RegisterFailure
このように成功と失敗を型で分けると、呼び出し側が失敗ケースを明示的に処理しやすくなります。
3-2. APIレスポンスの複数パターンを安全に扱いたい場合
APIでは、同じエンドポイントでも複数のレスポンスが返ることがあります。
C#200 OK
400 Bad Request
401 Unauthorized
404 Not Found
これをすべてobjectで返すと、呼び出し側は実行時まで型を確定できません。
C#public object GetUser(int id)
{
if (id <= 0) return new ValidationError("Invalid id");
if (id == 404) return new NotFound();
return new UserResponse(id, "Taro");
}
Union Type風にすると、次のように「返り得る型」を明示できます。
C#GetUserResponse =
UserResponse
| ValidationError
| Unauthorized
| NotFound
これにより、APIレスポンスの仕様とコード上の型が近づきます。
3-3. 状態遷移やドメインモデルを型で表現したい場合
ドメインモデルでは、状態によって持つべきデータが変わることがあります。
たとえば記事の状態を考えます。
C#Draft
Published
Rejected
enumで表すと、状態名だけは表現できます。
C#public enum ArticleStatus
{
Draft,
Published,
Rejected
}
しかし、状態ごとに異なるデータを持たせたい場合は不十分です。
C#Draft: 下書き作成日時
Published: 公開日時、公開者
Rejected: 却下理由
このような場合は、各状態を別のrecordとして定義し、共通の親型でまとめるとUnion Type風に扱えます。
3-4. 例外ではなくResult型でエラー処理したい場合
例外は便利ですが、通常の業務分岐に使いすぎると、処理の流れが見えにくくなります。
C#try
{
var user = service.GetUser(id);
Console.WriteLine(user.Name);
}
catch (UserNotFoundException)
{
Console.WriteLine("User not found.");
}
このコードは動きますが、「ユーザーが見つからない」という通常あり得る結果を例外で表しています。
Result型を使うと、戻り値として成功・失敗を表せます。
C#Result<User> result = service.GetUser(id);
呼び出し側は、switch式やMatchメソッドで成功と失敗を処理します。
3-5. null許容型だけでは不十分な場合
C#にはnullable reference typesがあります。これにより、stringとstring?の違いをコード上で表現できます。
C#string name = "Taro";
string? optionalName = null;
しかし、nullが表せるのは基本的に「値がない」という状態だけです。
次のような違いは、nullだけでは表現しにくいです。
C#// ユーザーが存在しない
// 権限がない
// 入力値が不正
// データベース接続に失敗した
これらをすべてnullで表すと、呼び出し側は理由を判定できません。Union Type風の設計では、理由ごとに型を分けられます。
4. recordを使ったC# Union Type風の代替実装
C#でUnion Type風の実装をする代表的な方法が、abstract recordと派生recordを使う方法です。
recordは値に基づく等価性、簡潔な構文、イミュータブルなデータ表現と相性が良く、結果型や状態型の表現に向いています。
4-1. abstract recordで共通の親型を定義する
まず、共通の親型をabstract recordで定義します。
C#public abstract record CreateUserResult;
このCreateUserResultは、ユーザー作成処理の結果を表す親型です。
この時点では、具体的な成功・失敗の情報は持っていません。あくまで「ユーザー作成結果」という概念を表します。
4-2. 派生recordで各ケースを表現する
次に、各ケースを派生recordで定義します。
C#public record CreateUserSuccess(User User) : CreateUserResult;
public record InvalidEmail(string Message) : CreateUserResult;
public record EmailAlreadyExists(string Email) : CreateUserResult;
これで、CreateUserResultは次のいずれかとして扱えます。
C#CreateUserSuccess
InvalidEmail
EmailAlreadyExists
Union Typeそのものではありませんが、「この処理結果は複数ケースのうちどれか」という設計をC#で表現できます。
4-3. switch式とパターンマッチで分岐する
呼び出し側では、switch式とパターンマッチを使って分岐します。
C#string message = result switch
{
CreateUserSuccess success => $"Created user: {success.User.Email}",
InvalidEmail invalid => invalid.Message,
EmailAlreadyExists exists => $"Email already exists: {exists.Email}",
_ => "Unknown result"
};
C#のパターンマッチを使うと、型ごとの分岐を簡潔に書けます。
ただし、C# 14以前のabstract recordによる実装では、コンパイラが「すべての派生型を網羅しているか」を完全には保証してくれません。そのため、最後に_を置くか、テストで分岐漏れを検出する設計が必要です。
4-4. record実装のサンプルコード
実際のサンプルを見てみましょう。
C#public record User(string Email);
public abstract record CreateUserResult;
public record CreateUserSuccess(User User) : CreateUserResult;
public record InvalidEmail(string Message) : CreateUserResult;
public record EmailAlreadyExists(string Email) : CreateUserResult;
public class UserService
{
private readonly HashSet<string> _emails = new()
{
"used@example.com"
};
public CreateUserResult CreateUser(string email)
{
if (string.IsNullOrWhiteSpace(email))
{
return new InvalidEmail("Email is required.");
}
if (!email.Contains('@'))
{
return new InvalidEmail("Email format is invalid.");
}
if (_emails.Contains(email))
{
return new EmailAlreadyExists(email);
}
var user = new User(email);
return new CreateUserSuccess(user);
}
}
呼び出し側は次のように書けます。
C#var service = new UserService();
CreateUserResult result = service.CreateUser("used@example.com");
string message = result switch
{
CreateUserSuccess success => $"User created: {success.User.Email}",
InvalidEmail error => $"Invalid email: {error.Message}",
EmailAlreadyExists error => $"Already exists: {error.Email}",
_ => "Unexpected result"
};
Console.WriteLine(message);
このコードでは、成功、入力エラー、重複エラーをそれぞれ別の型として表現しています。
4-5. recordで実装するメリット
recordでUnion Type風に実装するメリットは、C#標準機能だけで実現できることです。
外部ライブラリを追加する必要がなく、C#に慣れた開発者であれば読みやすい設計になります。
また、recordはデータ中心の型を表すのに向いています。
C#public record ValidationError(string Field, string Message);
このように、エラーや状態を簡潔に定義できます。
さらに、with式や値ベースの等価性も使えるため、ドメインモデルやテストコードとも相性が良いです。
C#var error = new ValidationError("Email", "Required");
var updated = error with { Message = "Invalid format" };
4-6. recordで実装するデメリットと注意点
最大の注意点は、完全な網羅性チェックが難しいことです。
たとえば、後から新しいケースを追加したとします。
C#public record PasswordTooWeak(string Message) : CreateUserResult;
このとき、既存のswitch式がPasswordTooWeakを処理していなくても、常にコンパイルエラーになるとは限りません。
C#string message = result switch
{
CreateUserSuccess success => $"User created: {success.User.Email}",
InvalidEmail error => error.Message,
EmailAlreadyExists error => error.Email,
_ => "Unexpected result"
};
_があるため、新しいケースはすべてUnexpected resultに流れてしまいます。
また、親型をpublic abstract recordにすると、別アセンブリから派生型を追加できる設計になることがあります。閉じた型集合を強く表したい場合は、アクセシビリティやファイル構成、コンストラクタの制御を慎重に設計する必要があります。
5. OneOfライブラリを使ったUnion Type実装
C#でUnion Type風の実装をより直接的に行いたい場合、OneOfライブラリがよく使われます。
OneOfは、OneOf<T0, T1>やOneOf<T0, T1, T2>のように、複数の型のうちどれか1つを表すためのライブラリです。
5-1. OneOfとは何か
OneOfは、C#でF#風の判別共用体を扱うためのライブラリです。NuGetの説明では、OneOf<T0, ... Tn>が単一の値を保持し、.Match(...)メソッドで網羅的なマッチングを行えると説明されています。nuget
GitHubのREADMEでも、OneOfはOneOf<T0, ... Tn>というカスタム型を使い、そのインスタンスがジェネリック引数リストのいずれか1つの値を保持すると説明されています。GitHub
たとえば、次のように書けます。
C#OneOf<User, NotFound, ValidationError>
これは、「User、NotFound、ValidationErrorのどれか1つ」を表します。
5-2. OneOfのインストール方法
OneOfはNuGetからインストールできます。
Bashdotnet add package OneOf
Package Manager Consoleを使う場合は、次のようにインストールします。
PowerShellInstall-Package OneOf
インストール後、コードで次の名前空間を使います。
C#using OneOf;
5-3. OneOf<T0, T1>の基本的な使い方
基本的な使い方はシンプルです。
C#using OneOf;
public record User(string Name);
public record NotFound(string Message);
public OneOf<User, NotFound> GetUser(int id)
{
if (id == 1)
{
return new User("Taro");
}
return new NotFound("User not found.");
}
このメソッドは、UserまたはNotFoundを返します。
呼び出し側は、返ってきた値を型ごとに処理できます。
5-4. Matchメソッドで型ごとの処理を分ける
OneOfでは、Matchメソッドを使って型ごとの処理を分けます。
C#var result = GetUser(2);
string message = result.Match(
user => $"User: {user.Name}",
notFound => notFound.Message
);
Console.WriteLine(message);
Matchでは、OneOfに指定した型ごとに処理を渡します。
C#OneOf<User, NotFound>
であれば、Userの場合の処理とNotFoundの場合の処理を指定します。
この書き方は、Union Typeに近い感覚で使えます。
5-5. 成功・失敗をOneOfで表現するサンプル
ユーザー作成処理をOneOfで表すと、次のようになります。
C#using OneOf;
public record User(string Email);
public record ValidationError(string Field, string Message);
public record DuplicateEmail(string Email);
public class UserService
{
private readonly HashSet<string> _emails = new()
{
"used@example.com"
};
public OneOf<User, ValidationError, DuplicateEmail> CreateUser(string email)
{
if (string.IsNullOrWhiteSpace(email))
{
return new ValidationError("Email", "Email is required.");
}
if (!email.Contains('@'))
{
return new ValidationError("Email", "Email format is invalid.");
}
if (_emails.Contains(email))
{
return new DuplicateEmail(email);
}
return new User(email);
}
}
呼び出し側は次のようになります。
C#var service = new UserService();
var result = service.CreateUser("used@example.com");
string message = result.Match(
user => $"Created: {user.Email}",
validationError => $"{validationError.Field}: {validationError.Message}",
duplicate => $"Duplicate email: {duplicate.Email}"
);
Console.WriteLine(message);
戻り値の型を見るだけで、このメソッドが返し得る結果が分かります。
5-6. OneOfを使うメリット
OneOfのメリットは、Union Typeに近い表現を簡潔に書けることです。
C#public OneOf<User, NotFound, ValidationError> GetUser(int id)
このシグネチャを見るだけで、呼び出し側は「このメソッドは3種類の結果を返す」と理解できます。
また、Matchメソッドを使うことで、型ごとの処理を1か所にまとめられます。
外部ライブラリを許容できるプロジェクトであれば、recordによる自前実装よりもUnion Typeらしいコードを書きやすくなります。
5-7. OneOfを使うデメリットと導入時の注意点
OneOfのデメリットは、外部ライブラリに依存することです。
チーム開発では、次の点を確認する必要があります。
C#// チーム全員がOneOfの書き方を理解できるか
// 既存の設計方針と合うか
// ASP.NET Coreのレスポンス型として扱いやすいか
// シリアライズ時に問題が起きないか
// デバッグ時に読みやすいか
また、OneOf<T0, T1, T2, ...>の型引数が多くなりすぎると、可読性が落ちます。
C#OneOf<A, B, C, D, E, F, G>
このような型が頻繁に出てくる場合は、そもそも設計を見直した方がよいこともあります。
6. パターンマッチでUnion Type風に扱う方法
C#のパターンマッチは、Union Type風の設計と非常に相性が良い機能です。
recordでケースを分けた場合でも、objectや共通インターフェースで受けた場合でも、is演算子やswitch式を使って型ごとに処理できます。
6-1. is演算子による型判定
最も基本的な方法は、is演算子を使った型判定です。
C#if (result is CreateUserSuccess success)
{
Console.WriteLine(success.User.Email);
}
else if (result is InvalidEmail invalid)
{
Console.WriteLine(invalid.Message);
}
else if (result is EmailAlreadyExists exists)
{
Console.WriteLine(exists.Email);
}
この書き方は分かりやすいですが、ケースが増えるとコードが長くなります。
また、戻り値を生成する側と処理する側が離れている場合、分岐漏れに気づきにくくなります。
6-2. switch式による型ごとの分岐
switch式を使うと、より簡潔に書けます。
C#string message = result switch
{
CreateUserSuccess success => $"Created: {success.User.Email}",
InvalidEmail invalid => invalid.Message,
EmailAlreadyExists exists => $"Already exists: {exists.Email}",
_ => "Unknown result"
};
switch式は、値を返す分岐に向いています。
HTMLメッセージ、ログメッセージ、HTTPステータス、画面表示用テキストなどを作るときに使いやすいです。
6-3. when句を使った条件付きパターン
when句を使うと、型だけでなく条件も組み合わせられます。
C#string message = result switch
{
InvalidEmail error when error.Message.Contains("required")
=> "メールアドレスは必須です。",
InvalidEmail error
=> $"メールアドレスが不正です: {error.Message}",
CreateUserSuccess success
=> $"作成しました: {success.User.Email}",
EmailAlreadyExists exists
=> $"すでに登録されています: {exists.Email}",
_ => "不明な結果です。"
};
型と値の条件を組み合わせられるため、実務ではかなり柔軟に使えます。
6-4. 網羅性チェックの限界
C#のパターンマッチは便利ですが、C# 14以前のabstract recordや継承ベースの実装では、完全な網羅性チェックには限界があります。
次のように新しいケースを追加しても、既存のswitch式が必ずコンパイルエラーになるとは限りません。
C#public record PasswordTooWeak(string Message) : CreateUserResult;
特に、_を使っている場合、新しいケースはデフォルト分岐に流れてしまいます。
C#_ => "Unknown result"
これは便利な一方で、分岐漏れを隠してしまう原因にもなります。
そのため、Union Type風に設計する場合は、次のような工夫が必要です。
C#// default分岐でログを出す
// テストで全ケースを検証する
// ケース追加時に呼び出し側のswitchを確認する
// 型の増えすぎを防ぐ
6-5. パターンマッチを使った実装例
注文処理の結果をパターンマッチで扱う例です。
C#public abstract record OrderResult;
public record OrderCompleted(int OrderId) : OrderResult;
public record PaymentFailed(string Reason) : OrderResult;
public record OutOfStock(string ProductCode) : OrderResult;
public class OrderService
{
public OrderResult PlaceOrder(string productCode, bool paymentOk)
{
if (productCode == "EMPTY")
{
return new OutOfStock(productCode);
}
if (!paymentOk)
{
return new PaymentFailed("Payment was declined.");
}
return new OrderCompleted(123);
}
}
呼び出し側です。
C#var service = new OrderService();
OrderResult result = service.PlaceOrder("BOOK", paymentOk: true);
string message = result switch
{
OrderCompleted completed => $"注文が完了しました。注文ID: {completed.OrderId}",
PaymentFailed failed => $"決済に失敗しました: {failed.Reason}",
OutOfStock stock => $"在庫がありません: {stock.ProductCode}",
_ => "不明な注文結果です。"
};
Console.WriteLine(message);
このように、recordとswitch式を組み合わせるだけでも、かなりUnion Typeに近い書き方ができます。
7. C#でUnion Typeを代替実装する方法の比較
C#でUnion Typeを代替する主な方法は、record、OneOf、パターンマッチの3つです。
実際には、これらは競合するというより組み合わせて使います。
C#// recordでケースを定義する
// OneOfで戻り値の候補を明示する
// switch式やMatchで処理する
7-1. record・OneOf・パターンマッチの違い
recordは、C#標準機能だけでケースを定義する方法です。
C#public abstract record Result;
public record Success(string Value) : Result;
public record Failure(string Error) : Result;
OneOfは、複数の型のうちどれか1つを戻り値として表すライブラリです。
C#public OneOf<Success, Failure> Execute()
パターンマッチは、返ってきた値を型ごとに処理する構文です。
C#result switch
{
Success success => success.Value,
Failure failure => failure.Error,
_ => throw new InvalidOperationException()
};
つまり、役割は次のように分かれます。
C#record: ケースを表す
OneOf: 複数候補の戻り値を表す
パターンマッチ: ケースごとの処理を書く
7-2. 型安全性で比較する
型安全性を高めたい場合、objectやdynamicは避けるべきです。
recordを使うと、共通の親型を通してある程度安全に扱えます。
C#public CreateUserResult CreateUser(string email)
ただし、この型がどの派生型を取り得るかは、メソッドシグネチャだけでは完全には分かりません。
OneOfを使うと、戻り値の候補がシグネチャに直接表れます。
C#public OneOf<User, ValidationError, DuplicateEmail> CreateUser(string email)
型安全性の明示という意味では、OneOfの方が分かりやすい場面があります。
7-3. 可読性で比較する
可読性はチームの慣れに左右されます。
C#らしいコードを重視するなら、recordの方が受け入れられやすいです。
C#public abstract record PaymentResult;
public record PaymentSucceeded(string TransactionId) : PaymentResult;
public record PaymentFailed(string Reason) : PaymentResult;
一方、関数型プログラミングやResult型に慣れているチームでは、OneOfの方が直感的な場合があります。
C#OneOf<PaymentSucceeded, PaymentFailed>
パターンマッチは、どちらの方法でも読みやすさを高めます。
7-4. 保守性で比較する
保守性を考えると、ケース追加時に既存コードへ影響が分かりやすい設計が重要です。
recordのみの実装では、ケース追加時にすべてのswitch分岐を人間が確認する必要があります。
OneOfでは、Matchの引数数と型が対応するため、ケースの変更がコード上に現れやすいです。ただし、型引数が多くなると保守性は下がります。
C#OneOf<A, B, C, D, E, F>
このような型が増えたら、設計を分割するサインです。
7-5. 導入コストで比較する
導入コストが低いのはrecordとパターンマッチです。C#標準機能だけで使えるため、追加の依存関係がありません。
OneOfはNuGetパッケージの導入が必要です。ただし、導入自体は簡単です。
Bashdotnet add package OneOf
チームやプロジェクトで外部ライブラリの追加に慎重な場合は、まずrecordベースの実装から始めるのが現実的です。
7-6. チーム開発で使いやすい方法はどれか
チーム開発では、分かりやすさとルール化のしやすさが重要です。
おすすめは、次のような使い分けです。
C#// 小規模・標準機能重視
abstract record + 派生record + switch式
// 戻り値の候補を明示したい
OneOf<T0, T1, T2>
// ドメイン状態を表したい
record階層
// APIレスポンスを複数型で返したい
recordまたはOneOf。ただしASP.NET Coreとの相性に注意
最初からすべてをOneOfに置き換える必要はありません。まずはResult<T>やAPIレスポンスなど、効果が分かりやすい場所から導入するとよいでしょう。
8. 実践例:Result型をUnion Type風に実装する
Union Type風の設計で最もよく使われるのがResult型です。
Result型は、処理結果を「成功」または「失敗」として表す型です。
8-1. Result型とは何か
Result型は、次のような考え方です。
C#Result<T> = Success<T> | Failure
成功した場合は値を持ち、失敗した場合はエラー情報を持ちます。
C#Success<User>
Failure
これにより、例外やnullに頼らず、戻り値として成功・失敗を表現できます。
8-2. SuccessとFailureをrecordで定義する
まず、抽象recordで親型を定義します。
C#public abstract record Result<T>;
次に、成功と失敗を定義します。
C#public record Success<T>(T Value) : Result<T>;
public record Failure<T>(Error Error) : Result<T>;
エラー情報もrecordにします。
C#public record Error(string Code, string Message);
8-3. エラー情報を型として持たせる
エラー情報を単なる文字列にすると、扱いが雑になりやすいです。
C#public record Error(string Code, string Message);
エラーコードを持たせると、呼び出し側で条件分岐しやすくなります。
C#public static class ErrorCodes
{
public const string NotFound = "not_found";
public const string Validation = "validation_error";
public const string Unauthorized = "unauthorized";
}
サービス側の実装例です。
C#public record User(int Id, string Name);
public class UserService
{
public Result<User> GetUser(int id)
{
if (id <= 0)
{
return new Failure<User>(
new Error(ErrorCodes.Validation, "Id must be greater than zero.")
);
}
if (id == 404)
{
return new Failure<User>(
new Error(ErrorCodes.NotFound, "User was not found.")
);
}
return new Success<User>(new User(id, "Taro"));
}
}
8-4. 呼び出し側でswitch式を使って処理する
呼び出し側では、switch式で成功と失敗を分けます。
C#var service = new UserService();
Result<User> result = service.GetUser(404);
string message = result switch
{
Success<User> success => $"User: {success.Value.Name}",
Failure<User> failure => failure.Error.Code switch
{
ErrorCodes.NotFound => "ユーザーが見つかりません。",
ErrorCodes.Validation => $"入力エラー: {failure.Error.Message}",
_ => $"エラー: {failure.Error.Message}"
},
_ => "不明な結果です。"
};
Console.WriteLine(message);
この設計では、呼び出し側が失敗の可能性を無視しにくくなります。
8-5. 例外処理との使い分け
Result型を導入しても、例外が不要になるわけではありません。
Result型に向いているのは、業務上予測できる失敗です。
C#// Result型に向いている
ユーザーが見つからない
入力値が不正
在庫がない
認証に失敗した
例外に向いているのは、予期しない異常です。
C#// 例外に向いている
データベース接続が突然切れた
設定ファイルが壊れている
外部サービスが想定外の形式を返した
プログラム上の不変条件が破られた
通常フローはResult型、異常系は例外、という使い分けにするとコードの意図が明確になります。
9. 実践例:APIレスポンスをUnion Type風に表現する
Web APIでは、複数のレスポンスパターンを型で表すと設計が分かりやすくなります。
特に、ASP.NET CoreではHTTPステータスとレスポンスボディの組み合わせを明確にすることが重要です。
9-1. 正常レスポンス・バリデーションエラー・認証エラーを分ける
ユーザー取得APIを例にします。
返り得るレスポンスは次の3つとします。
C#UserResponse
ValidationErrorResponse
UnauthorizedResponse
それぞれをrecordで定義します。
C#public record UserResponse(int Id, string Name);
public record ValidationErrorResponse(string Field, string Message);
public record UnauthorizedResponse(string Message);
このように型を分けることで、レスポンスごとの意味が明確になります。
9-2. objectやdynamicを避けるべき理由
APIレスポンスで次のような実装をすると、短期的には楽です。
C#public object GetUser(int id)
{
if (id <= 0)
{
return new { Field = "id", Message = "Invalid id" };
}
return new { Id = id, Name = "Taro" };
}
しかし、objectやdynamicを多用すると、次の問題が起きやすくなります。
C#// 返り得る型がメソッドシグネチャから分からない
// リファクタリングに弱い
// OpenAPIやSwaggerの定義とズレやすい
// 呼び出し側で型チェックが増える
// 実行時エラーに気づきにくい
APIは外部との契約です。可能な限り型で表現した方が保守しやすくなります。
9-3. recordでレスポンス型を定義する
recordと抽象親型を使う例です。
C#public abstract record GetUserApiResponse;
public record GetUserOk(UserResponse User) : GetUserApiResponse;
public record GetUserValidationError(ValidationErrorResponse Error) : GetUserApiResponse;
public record GetUserUnauthorized(UnauthorizedResponse Error) : GetUserApiResponse;
サービス層では、この型を返します。
C#public class UserQueryService
{
public GetUserApiResponse GetUser(int id, bool isAuthenticated)
{
if (!isAuthenticated)
{
return new GetUserUnauthorized(
new UnauthorizedResponse("Authentication is required.")
);
}
if (id <= 0)
{
return new GetUserValidationError(
new ValidationErrorResponse("id", "Id must be greater than zero.")
);
}
return new GetUserOk(new UserResponse(id, "Taro"));
}
}
コントローラー側でHTTPレスポンスに変換します。
C#[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var result = _service.GetUser(id, User.Identity?.IsAuthenticated == true);
return result switch
{
GetUserOk ok => Ok(ok.User),
GetUserValidationError error => BadRequest(error.Error),
GetUserUnauthorized error => Unauthorized(error.Error),
_ => StatusCode(500)
};
}
この設計では、サービス層は業務的な結果を返し、コントローラー層がHTTPに変換します。
9-4. OneOfでレスポンス型を定義する
OneOfを使うと、戻り値の型にレスポンス候補を直接書けます。
C#using OneOf;
public class UserQueryService
{
public OneOf<UserResponse, ValidationErrorResponse, UnauthorizedResponse> GetUser(
int id,
bool isAuthenticated)
{
if (!isAuthenticated)
{
return new UnauthorizedResponse("Authentication is required.");
}
if (id <= 0)
{
return new ValidationErrorResponse("id", "Id must be greater than zero.");
}
return new UserResponse(id, "Taro");
}
}
コントローラー側ではMatchを使います。
C#[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
var result = _service.GetUser(id, User.Identity?.IsAuthenticated == true);
return result.Match<IActionResult>(
user => Ok(user),
validationError => BadRequest(validationError),
unauthorized => Unauthorized(unauthorized)
);
}
この書き方は、APIの戻り値候補が非常に明確です。
9-5. ASP.NET Coreで使う場合の注意点
ASP.NET CoreでUnion Type風のレスポンスを使う場合、注意すべき点があります。
まず、OpenAPIやSwaggerの出力です。OneOfや抽象recordをそのまま返すと、期待通りのスキーマが生成されないことがあります。
また、コントローラーの戻り値として直接Union Type風の型を返すよりも、アプリケーション層ではUnion Type風の結果を返し、コントローラー層でIActionResultやResultsに変換する方が扱いやすいことが多いです。
C#// アプリケーション層
OneOf<UserResponse, ValidationErrorResponse, UnauthorizedResponse>
// プレゼンテーション層
IActionResult
このように責務を分けると、ドメインロジックとHTTPの都合を分離できます。
10. C# Union Typeの代替実装でよくある失敗
Union Type風の設計は便利ですが、使い方を間違えると逆に複雑になります。ここでは、よくある失敗を整理します。
10-1. objectで何でも返してしまう
最も避けたいのが、objectで何でも返す設計です。
C#public object Execute()
{
if (DateTime.Now.Second % 2 == 0)
{
return "success";
}
return 123;
}
このコードはコンパイルできますが、戻り値の意味が分かりません。
呼び出し側は、実行時に型を判定するしかありません。
C#var result = Execute();
if (result is string text)
{
Console.WriteLine(text);
}
else if (result is int number)
{
Console.WriteLine(number);
}
型安全性を高めるためには、objectではなく、具体的な結果型を定義するべきです。
10-2. nullで状態を表現してしまう
nullで複数の意味を表すのも危険です。
C#public User? GetUser(int id)
{
if (id <= 0)
{
return null;
}
if (id == 404)
{
return null;
}
return new User(id, "Taro");
}
このコードでは、id <= 0の入力エラーと、id == 404の未検出がどちらもnullです。
呼び出し側は理由を区別できません。
C#if (user is null)
{
Console.WriteLine("なぜnullなのか分からない");
}
理由を区別したいなら、ValidationErrorやNotFoundなどの型を使うべきです。
10-3. 例外を通常フローに使いすぎる
例外を通常フローに使いすぎると、コードの見通しが悪くなります。
C#public User GetUser(int id)
{
if (id == 404)
{
throw new UserNotFoundException();
}
return new User(id, "Taro");
}
ユーザーが存在しないことが業務上よくあるなら、それは例外ではなく結果として扱う方が自然です。
C#public Result<User> GetUser(int id)
例外は、予期しない異常や復旧困難な問題に使うと効果的です。
10-4. 継承階層が複雑になりすぎる
recordや抽象クラスでUnion Type風に実装する場合、継承階層が複雑になりすぎないように注意が必要です。
C#Result
ApiResult
UserApiResult
GetUserResult
GetUserSuccess
GetUserFailure
このように階層が深くなると、Union Type風のシンプルさが失われます。
基本的には、1つの用途に対して1つの親型を用意し、その直下にケースを並べる程度が扱いやすいです。
C#public abstract record GetUserResult;
public record GetUserSuccess(User User) : GetUserResult;
public record GetUserNotFound(int Id) : GetUserResult;
public record GetUserValidationError(string Message) : GetUserResult;
10-5. switchの分岐漏れに気づきにくい
recordベースの代替実装では、分岐漏れに注意が必要です。
C#string message = result switch
{
GetUserSuccess success => success.User.Name,
GetUserNotFound => "Not found",
_ => "Unknown"
};
新しいケースを追加しても、_に吸収される可能性があります。
C#public record GetUserUnauthorized() : GetUserResult;
この場合、認証エラーがUnknownとして処理されるかもしれません。
対策として、_分岐ではログを出したり、想定外として例外を投げたりする方法があります。
C#_ => throw new InvalidOperationException(
$"Unhandled result type: {result.GetType().Name}"
)
ただし、ユーザー向けAPIでそのまま例外を返すのは避け、適切にハンドリングする必要があります。
11. C# Union Typeに関するよくある質問
ここでは、C#のUnion Typeに関するよくある疑問を整理します。
11-1. C#にUnion Typeは標準機能としてありますか?
C# 14以前の安定版には、TypeScriptのA | BやF#の判別共用体のようなUnion Type構文はありません。
一方で、C# 15 Previewではunionキーワードが導入され始めています。Microsoftの.NET Blogでは、.NET 11 Preview 2からC# 15のUnion Typeを試せると説明されています。Microsoft for Developers
ただし、Preview機能であるため、安定版の実務プロジェクトでは引き続きrecord、OneOf、パターンマッチによる代替実装が現実的です。
11-2. recordだけでUnion Typeは実現できますか?
完全なUnion Typeではありませんが、Union Type風の設計は可能です。
C#public abstract record Result;
public record Success(string Value) : Result;
public record Failure(string Message) : Result;
このように、共通の親型と派生recordを使えば、複数ケースのうちどれかを表現できます。
ただし、C# 14以前では完全な網羅性チェックや閉じた型集合の保証は難しいため、設計ルールやテストで補う必要があります。
11-3. OneOfは本番環境で使っても問題ありませんか?
OneOfはNuGetで提供されているC#向けのUnion Type風ライブラリです。NuGet上では、OneOfがOneOf<T0, ... Tn>で単一の値を保持し、Matchで型ごとの処理を行うライブラリとして説明されています。nuget
本番環境で使うかどうかは、チームの方針、依存ライブラリの管理、メンテナンス状況、設計との相性を確認して判断するべきです。
特に、ライブラリに依存した型がアプリケーション全体に広がると、後から置き換えにくくなります。まずはアプリケーション層やユースケース層など、範囲を決めて導入するのがおすすめです。
11-4. discriminated unionとUnion Typeは同じですか?
厳密には文脈によって意味が異なります。
Union Typeは広く「複数の型のどれか1つ」を表す型です。
Discriminated Unionは、各ケースを識別できるタグやケース名を持つUnion Typeの一種として扱われることが多いです。
F#の判別共用体では、各ケースに名前があり、ケースごとに異なる値を持てます。Microsoft Learnでも、F#の判別共用体は複数の名前付きケースのいずれかを表す仕組みとして説明されています。Microsoft Learn
C#でrecordを使って次のように書く場合も、判別共用体に近い設計と言えます。
C#public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
11-5. enumや継承とは何が違いますか?
enumは、名前付きの定数を表す仕組みです。
C#public enum PaymentStatus
{
Succeeded,
Failed,
Pending
}
しかし、各ケースに異なるデータを持たせることはできません。
C#// enumだけでは表しにくい
Succeeded: TransactionId
Failed: Reason
Pending: ExpireAt
継承を使うと、ケースごとに異なるデータを持たせられます。
C#public abstract record PaymentResult;
public record PaymentSucceeded(string TransactionId) : PaymentResult;
public record PaymentFailed(string Reason) : PaymentResult;
ただし、通常の継承では「この型はこのケースだけに閉じている」という保証が弱くなります。
Union Typeは、理想的には「取り得るケースが型定義の中で閉じている」ことが特徴です。
11-6. F#の判別共用体をC#から使えますか?
使えます。F#で定義した判別共用体は.NETの型としてコンパイルされるため、C#から参照できます。
ただし、C#から使う場合はF#ほど自然な構文では扱えないことがあります。
F#側で次のように定義したとします。
fsharptype PaymentResult =
| Succeeded of transactionId: string
| Failed of reason: string
C#からは、生成された型やメソッドを通じて扱うことになります。
F#とC#を同じソリューションで使っている場合、ドメインモデルやResult型をF#で定義し、C#から呼び出す設計も可能です。ただし、チーム全体がF#の型や生成コードの扱いに慣れている必要があります。
まとめ
C# Union Typeは、「複数の型のうちどれか1つ」を安全に表現するための考え方です。
C# 14以前の安定版では、TypeScriptのA | BやF#の判別共用体のようなUnion Type構文はありませんでした。そのため、実務ではabstract recordと派生record、OneOfライブラリ、パターンマッチを組み合わせてUnion Type風に実装する方法がよく使われます。
recordを使う方法は、C#標準機能だけで実装でき、チームに導入しやすいのがメリットです。一方で、完全な網羅性チェックには限界があります。
OneOfを使う方法は、戻り値の候補をOneOf<User, NotFound, ValidationError>のように明示でき、Union Typeに近い書き方ができます。ただし、外部ライブラリへの依存や、型引数が増えたときの可読性には注意が必要です。
パターンマッチは、recordベースの実装でもOneOfでも重要です。switch式やis演算子を使うことで、型ごとの処理を分かりやすく書けます。
特に、Result型、APIレスポンス、状態遷移、ドメインモデル、エラー処理では、Union Type風の設計が大きな効果を発揮します。
今後、C# 15以降でUnion Typeが安定して利用できるようになれば、C#での型安全な分岐表現はさらに強力になります。ただし、それまでの間も、record、OneOf、パターンマッチを理解しておくことで、null、例外、objectに頼りすぎない堅牢なC#コードを書けるようになります。

