C# 15にunion型が入った
目次
C#に長年リクエストされ続けてきたunion型が、C# 15 / .NET 11 Preview 2でプレビュー利用可能になった。
新しい union キーワードで「値は固定された型の集合のうちどれか1つ」を宣言でき、コンパイラが switch 式の網羅性(exhaustiveness)を検証する。
公式ブログ記事を元に、設計思想と実装を整理した。
unionの基本構文
最もシンプルな宣言はこうなる。
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
union キーワードの後にケース型を列挙するだけ。
ケース型同士に継承関係は不要で、まったく無関係な型を並べてもいい。
変数への代入は暗黙変換で自然に書ける。
Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // Dog { Name = Rex }
パターンマッチングでは全ケースを網羅すれば _ やdefaultが不要になる。
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
ケースを1つ書き忘れたらコンパイルエラー。
これまで object や抽象基底クラスで無理やり表現していた「この変数は3種類のうちどれか」を、型安全に宣言できるようになった。
「ユニオン」の多義性
「ユニオン」はプログラミングの文脈で複数の異なる意味を持つ語だ。
C# 15のunionが何であって何でないのか、先に整理しておく。
Cの union はメモリレイアウトを共有する仕組み。
全メンバが同じメモリ領域を占有し、どのメンバが有効かはプログラマが自分で管理する。
型安全性はなく、C++では間違ったメンバへのアクセスが未定義動作になる。
union Value {
int i;
float f;
};
Value v;
v.i = 42;
printf("%f\n", v.f); // 未定義動作
SQLの UNION はクエリの結果セットを結合する演算子で、型システムとはまったく別の話。
MySQLやPostgreSQLで SELECT ... UNION SELECT ... として日常的に使うが、ここで議論するunion型とは概念レベルで異なる。
集合論のunion(和集合)は2つの集合を合わせた集合を指す。
TypeScriptの A | B やPythonの Union[A, B] はこの数学的概念が名前の由来。
C# 15の union はこれらのどれとも違う。
既存の型を列挙して閉じた型集合を定義し、コンパイラがパターンマッチングの網羅性を保証する型安全な仕組みだ。
Cのメモリ共有unionとは名前が同じだけで設計思想が根本的に異なる。
各言語のunion的機能
「値は有限個の型のうちどれか1つ」を表現する仕組みは多くの言語に存在する。
union型、判別union、代数的データ型(ADT)、sealed hierarchyと呼び名は様々だが、解きたい問題は同じだ。
以下、アプローチ別に整理する。
メモリ共有型: CとC++
Cの union は前述のとおりメモリ共有。
型安全性がないため、タグ付けが欲しければ struct + enum で手動タグドunionを組むことになる。
typedef enum { INT_VAL, FLOAT_VAL, STR_VAL } ValueTag;
typedef struct {
ValueTag tag;
union { int i; float f; const char* s; };
} TaggedValue;
タグの管理を忘れた瞬間にバグが生まれる。
何十年もこのパターンで事故が起きてきた結果、C++17で std::variant が標準に入った。
std::variant<int, float, std::string> v = 42;
std::visit(overloaded {
[](int i) { std::cout << "int: " << i; },
[](float f) { std::cout << "float: " << f; },
[](const std::string& s) { std::cout << "str: " << s; }
}, v);
std::visit でパターンマッチングに近い分岐ができるが、コンパイラが網羅性を保証するわけではない。
ケースを書き忘れてもコンパイルは通る。
代数的データ型: 判別unionの系譜
HaskellやML系言語が原型のアプローチ。
各ケースに名前(コンストラクタやタグ)を付け、パターンマッチングで分岐する。
コンパイラが網羅性を保証するのが共通の特徴。
Haskellは代数的データ型の代表格。
data 宣言でコンストラクタを列挙し、パターンマッチでケース不足があればコンパイル時に警告が出る。
data Shape = Circle Double
| Rectangle Double Double
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
F#は.NET上の関数型言語で、判別union(Discriminated Union)が言語の中核機能。
match 式の網羅性をコンパイラが検証する。
同じ.NETランタイム上にありながらC#にはずっとこの機能がなく、F#ユーザーからは「なぜC#にないのか」と言われ続けてきた。
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle(w, h) -> w * h
Rustの enum はデータを持てるバリアントで構成される。
所有権システムとの組み合わせでメモリ安全かつゼロコスト抽象。
match は網羅性必須で、不足はコンパイルエラー。
Rustが特に優れているのはniche optimization(タグ最適化)だ。
Option<&T> の場合、参照は0(null)になり得ないことをコンパイラが知っているので、0をNoneの判別タグとして流用する。
結果、Option<&T> は生の参照と同じサイズになる。タグ分のメモリが完全にゼロコスト。
enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
}
Swiftの enum もassociated value(関連値)を持てる。
switch は網羅性必須。
enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func area(_ s: Shape) -> Double {
switch s {
case .circle(let r): return .pi * r * r
case .rectangle(let w, let h): return w * h
}
}
OCamlはML系の直系で、バリアント型とパターンマッチングが言語の根幹。
Haskellとほぼ同じ表現力を持つ。
type shape =
| Circle of float
| Rectangle of float * float
let area = function
| Circle r -> Float.pi *. r *. r
| Rectangle (w, h) -> w *. h
これらの言語に共通するのは「ケースごとに新しいコンストラクタ名を定義する」点だ。
既存の型をそのまま放り込むのではなく、Circle of float のように専用の名前を付ける。
ドメインの意味が型の定義に組み込まれるメリットがある一方、既存の型を再利用したいときにはラッパーを書く手間が生じる。
型union: 既存の型をそのまま使う
「新しいコンストラクタを定義せず、既存の型をそのまま集合にする」アプローチ。
C# 15はここに位置する。
TypeScriptのunion typeは | で型を並べる構文で、JavaScript/TypeScriptエコシステムでは最も馴染み深い形式だろう。
type StringOrNumber = string | number;
function format(value: StringOrNumber): string {
if (typeof value === "string") return value.toUpperCase();
return value.toFixed(2);
}
判別unionが欲しければリテラル型のタグフィールドを使うのが定番。
type Shape =
| { type: "circle"; radius: number }
| { type: "rectangle"; width: number; height: number };
function area(s: Shape): number {
switch (s.type) {
case "circle": return Math.PI * s.radius ** 2;
case "rectangle": return s.width * s.height;
}
}
TypeScriptの switch は --strictNullChecks とnever型の組み合わせで網羅性チェックが可能。
ただしデフォルトで有効にはならず、プロジェクトの設定に依存する。
Scala 3のunion typeは宣言なしで型を合成できる。
def format(value: Int | String): String = value match
case i: Int => i.toString
case s: String => s.toUpperCase
C# 15との大きな違いは匿名性。
Scala 3の Int | String は名前のないその場限りの型で、union キーワードで明示的に新しい名前付き型を定義するC#とは設計が異なる。
match の網羅性チェックはsealed traitと組み合わせた場合のみ有効で、裸のunion typeでは効かない。
Pythonは3.10から X | Y 構文でunion型を書けるようになった。
def format(value: int | str) -> str:
if isinstance(value, str):
return value.upper()
return f"{value:.2f}"
Pythonの型注釈はランタイムで強制されない。
mypyやpyrightなどの静的型チェッカーがないと、float を渡してもエラーにならない。
網羅性チェックもmypy 1.x系で部分的にサポートされ始めた段階で、コンパイラ保証とは言えない。
sealed hierarchy: 継承ベースの閉じた型集合
unionキーワードは持たないが、継承を制限することで閉じた型の集合を作るアプローチ。
判別unionと型unionの中間的な位置づけになる。
Kotlinのsealed classは、直接のサブクラスを同一パッケージ内に限定する。
when 式(Kotlinのswitch相当)で全サブクラスを網羅すれば else が不要。
sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val width: Double, val height: Double) : Shape
fun area(s: Shape): Double = when (s) {
is Circle -> Math.PI * s.radius * s.radius
is Rectangle -> s.width * s.height
}
JavaはJava 17でsealed classes、Java 21でswitch式のパターンマッチング(正式版)が揃った。
recordとsealed interfaceの組み合わせでKotlinと同等の表現力を持つ。
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
Java 21のswitch式はsealed型に対して網羅性を検証する。
KotlinもJavaも、ケースを追加したときにswitch/whenの修正漏れがコンパイルエラーになる。
sealed hierarchyの難点は、ケースを追加するたびに新しいclass/recordを定義してインターフェースを実装しなければならないこと。
「int と string のどちらか」のような単純なunionを表現するのにラッパークラスが2つ必要になり、ボイラープレートが増える。
全体比較
| 言語 | 方式 | 網羅性保証 | 既存型の再利用 | メモリ効率 |
|---|---|---|---|---|
| C | untagged union | - | - | ◎ メモリ共有 |
C++17 variant | tagged union | - | ◯ | ◯ |
| Haskell | ADT | ◯ | - | ◯ |
| OCaml | バリアント型 | ◯ | - | ◯ |
| F# | 判別union | ◯ | - | ◯ |
Rust enum | ADT | ◯ | - | ◎ タグ最適化 |
Swift enum | ADT | ◯ | - | ◯ |
| TypeScript | 型union | △ 設定依存 | ◯ | N/A |
| Scala 3 | 型union | △ sealed時のみ | ◯ | JVM依存 |
| Python | 型union | △ 外部ツール | ◯ | N/A |
| Kotlin | sealed hierarchy | ◯ | - | JVM依存 |
| Java 21 | sealed hierarchy | ◯ | - | JVM依存 |
| C# 15 | 型union | ◯ | ◯ | △ boxing |
C# 15が特異なのは「型union + コンパイラによる網羅性保証」の組み合わせだ。
TypeScriptやScala 3の型unionは網羅性チェックが弱く、設定やsealedに依存する。
Kotlin/Javaのsealed hierarchyは網羅性が強いが、ケースごとに専用の型定義が必要。
C# 15は既存のrecord classやstructをそのまま列挙しつつ、コンパイラが全ケースの網羅を保証する。
このポジションは他の主要言語にない。
トレードオフもある。
内部的に object? フィールドに格納するためvalue typeはboxingされるし、Rust enumのようなタグ最適化もない。
パフォーマンスクリティカルな場面では後述のnon-boxingパターンが必要になる。
判別unionが欲しければ、別途提案されている「closed hierarchy」(後述)と組み合わせることでF#的なパターンも表現可能になる見込みだ。
ジェネリック対応とメソッド定義
union宣言にはジェネリック型パラメータを使える。
bodyを持たせてメソッドを定義することもできる。
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
T single => [single],
IEnumerable<T> multiple => multiple,
null => []
};
}
使い方は直感的だ。
OneOrMore<string> tags = "dotnet";
OneOrMore<string> moreTags = new[] { "csharp", "unions", "preview" };
foreach (var tag in tags.AsEnumerable())
Console.WriteLine(tag);
Value プロパティに対するswitchでは null アームが必要になる。
union型のデフォルト値は Value が null になるため、コンパイラはnullケースも網羅性チェックの対象にする。
内部実装とUnionAttribute
コンパイラ生成のunion宣言は、内部的には object? 型の単一フィールドに値を格納するstruct(record struct)として実装される。
値型のケースはboxingされる。
自前の型にunion動作を持たせたい場合は [System.Runtime.CompilerServices.Union] 属性を付ける。
Preview 2時点ではランタイムにこの属性が含まれていないため、自分で宣言する必要がある。
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class UnionAttribute : Attribute;
public interface IUnion
{
object? Value { get; }
}
}
ライブラリ作者は既存の型を後からunion対応にできる。
コンストラクタベースの「union creation member」と Value プロパティを実装すれば、コンパイラが暗黙変換とパターンマッチングの網羅性検証を提供する。
ボクシング回避のためのnon-boxingアクセスパターン
Value プロパティは object? 型なので、int や bool のような値型ケースではboxingが発生する。
前節の比較表で「△ boxing」と書いた部分だ。
パフォーマンスが重要な場面では「non-boxing union access pattern」を実装できる。
[Union]
public record struct IntOrBool
{
private bool _isBool;
private int _value;
public IntOrBool(int value) => (_isBool, _value) = (false, value);
public IntOrBool(bool value) => (_isBool, _value) = (true, value ? 1 : 0);
public object Value => _isBool ? _value is 1 : _value;
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value is 1;
return _isBool;
}
}
HasValue プロパティと各ケース型ごとの TryGetValue メソッドを実装すると、コンパイラのパターンマッチングがboxingなしで値を取り出すコードを生成する。
union宣言の糖衣構文では使えないが、ライブラリが [Union] 属性で独自実装する場合に効いてくる。
Rust enumのタグ最適化には及ばないが、.NETのGC環境でのboxing回避としては妥当なアプローチだ。
網羅性検証の仕組み
C# 15のunion matching(パターンマッチング)は、パターンの種類によって動作が変わる。
flowchart TD
A["switch式にunion値が来る"] --> B{"パターンの種類は?"}
B -->|"var / _"| C["union値そのものに適用"]
B -->|"型パターン<br/>nullパターン<br/>プロパティパターン"| D[".Valueプロパティを<br/>自動アンラップ"]
D --> E{"全ケース型を<br/>網羅している?"}
E -->|Yes| F["コンパイル成功<br/>default不要"]
E -->|No| G["コンパイルエラー<br/>CS8509"]
var pet と書けばunion値自体が束縛される。
Dog d のような型パターンでは .Value が自動的にアンラップされ、中身に対してマッチングが行われる。
この自動アンラップのおかげで、利用者側は pet.Value switch { ... } と書く必要がない。
union値がclass型のとき、null パターンはunion値自体がnullの場合と、Value がnullの場合の両方にマッチする。
struct unionの場合は Value がnullのケースのみ。
Haskell/Rust/Swiftの網羅性検証が「コンストラクタを全列挙したか」で判定するのに対し、C# 15は「ケースとして宣言された型を全カバーしたか」で判定する。
結果は同じだが、判定の対象が「コンストラクタ名」か「型」かという違いがある。
Result型への応用
union member providerパターンを使えば、関数型言語でおなじみのResult型も自然に実装できる。
[Union]
public record class Result<T> : Result<T>.IUnionMembers
{
object? _value;
public interface IUnionMembers
{
public static Result<T> Create(T value) => new() { _value = value };
public static Result<T> Create(Exception value) => new() { _value = value };
public object? Value { get; }
}
object? IUnionMembers.Value => _value;
}
IUnionMembers インターフェースをunion-defining typeとして使うこのパターンは、コンストラクタではなく Create ファクトリメソッドでケース型を定義する。
内部のストレージ戦略を完全に制御しつつ、コンパイラの網羅性保証を受けられる。
F#の Result<'T, 'TError> やRustの Result<T, E> に相当するものを、C#でも型安全に書けるようになった。
これまでは例外に頼るか、(bool success, T? value, Exception? error) のようなタプルでお茶を濁すしかなかったことを思えば、大きな進歩だ。
今後のロードマップ
unionと連動する提案がいくつか進行中だ。
closed hierarchy は closed 修飾子をクラスに付けて、派生クラスの宣言を定義アセンブリ内に制限する。
Kotlin/Javaのsealed classに相当する機能で、abstract classベースの判別unionを網羅的にパターンマッチングしたい場合に使える。
仕様提案はすでに公開されている。
union型と組み合わせれば、F#的な判別unionパターンもC#で表現可能になる。
closed enum は宣言されたメンバー以外の値を持てないenumを作る機能で、(MyEnum)999 のようなキャストによる不正値を防ぐ。
union member provider は、ケース型に共通するプロパティへの直接アクセスを可能にする機能だが、Preview 2時点では未実装。
将来のプレビューで追加予定とされている。
Minimal APIでのレスポンス型
ASP.NET Core 7で導入された Results<T1, T2, ...> は、エンドポイントが複数のHTTPレスポンス型を返すときに使う型だ。
app.MapGet("/users/{id}", Results<Ok<User>, NotFound> (int id) =>
{
var user = db.Find(id);
return user is not null
? TypedResults.Ok(user)
: TypedResults.NotFound();
});
内部実装は Results<T1, T2> から Results<T1, ..., T6> までのジェネリック型で、7型以上のレスポンスは表現できない。
union型ならこの制限がなくなる。
public union ApiResponse(
Ok<User>,
NotFound,
BadRequest<ValidationProblem>,
Conflict,
TooManyRequests,
ServiceUnavailable,
InternalServerError
);
app.MapGet("/users/{id}", ApiResponse (int id) =>
{
// 7型以上でも問題ない
});
OpenAPI(Swagger)のスキーマ自動生成との統合はPreview 2時点で未対応。
union型のケース一覧からレスポンススキーマを自動生成する仕組みは自然な拡張方向で、GA前に対応が入る可能性はある。
Blazorコンポーネントの状態表現
SPAで頻出する「ロード中 / 成功 / エラー」の3状態。
Blazorだと bool isLoading と T? data と string? error をバラバラのフィールドで管理しがちだった。
「ロード中なのにdataがnullじゃない」みたいな不整合が実行時まで見えない。
public record Loading;
public record Loaded<T>(T Data);
public record Failed(string Message);
public union PageState<T>(Loading, Loaded<T>, Failed);
コンポーネント側はswitchで分岐する。
@switch (State)
{
case Loading:
<Spinner />
break;
case Loaded<WeatherForecast[]> loaded:
<ForecastTable Data="@loaded.Data" />
break;
case Failed f:
<ErrorBanner Message="@f.Message" />
break;
}
3状態が型レベルで排他になるので、状態の不整合がコンパイル時に弾かれる。
ReactでTypeScriptのtagged unionが状態管理に使われているのと同じ発想だが、C#ではコンパイラの網羅性保証がつく。
ケースを追加したときにswitchの修正漏れがエラーになるのは、画面数が増えたプロジェクトでかなり効く。
System.Text.Jsonシリアライズの現状
Web APIでunion型を使うなら、JSONシリアライズの挙動は避けて通れない。
Preview 2時点での System.Text.Json の動作を整理する。
union型の Value プロパティは object? 型なので、デフォルトのシリアライザはランタイム型に基づいてシリアライズする。
public union ApiResult(User, ErrorInfo);
var result = (ApiResult)new User("Alice", "alice@example.com");
var json = JsonSerializer.Serialize(result);
// {"Value":{"Name":"Alice","Email":"alice@example.com"}}
シリアライズ側は動くが、デシリアライズ側は型情報が失われるのでそのままでは復元できない。
カスタム JsonConverter を書くか、.NET 7で追加された JsonDerivedType 属性によるポリモーフィックシリアライズと組み合わせる必要がある。
public class ApiResultConverter : JsonConverter<ApiResult>
{
public override ApiResult Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// 判別フィールドで分岐する例
if (root.TryGetProperty("Email", out _))
return JsonSerializer.Deserialize<User>(root, options)!;
return JsonSerializer.Deserialize<ErrorInfo>(root, options)!;
}
public override void Write(
Utf8JsonWriter writer,
ApiResult value,
JsonSerializerOptions options)
{
JsonSerializer.Serialize(
writer,
value.Value,
value.Value?.GetType() ?? typeof(object),
options);
}
}
正直、ここはまだ荒削りだ。
TypeScriptのように判別フィールド("type": "user" など)をシリアライザが自動付与してくれる仕組みが欲しい。
System.Text.Json 側のunion対応がGA前に入るのか、サードパーティのJsonConverterファクトリに任されるのかは、今後のプレビューで見えてくるだろう。
試すには
.NET 11 Preview 2 SDKをインストールし、プロジェクトファイルで以下を設定する。
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
Preview 2では UnionAttribute と IUnion インターフェースがランタイムに含まれていないため、上述のコードを自分のプロジェクトに追加する必要がある。
C#チームのMads Torgersenは、unionがC# 15(2026年11月予定)の正式リリースに含まれるかは明言していない。
C# 14のGA後に本格的な作業が始まり、間に合えば入るというスタンスだ。
Preview段階のフィードバックが設計に影響するので、GitHub issueで意見を出すなら今がいい。