vatt'ghern jaskier's ballads
本文 5 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

SupportedOS os = new MacOS("Tahoe", 25); 這行,等號右邊是一個 record、左邊是一個還不存在的型別——C# 15 的 compiler 會偷偷把 MacOS 塞進一個它自動生成的 structobject? Value 欄位裡,然後在你 switch 它時,幫你檢查有沒有漏掉任何一個 case。整個 discriminated union 機制就是這層「struct + IUnion + 編譯期窮舉檢查」的薄包裝,沒有新的 runtime、沒有新的 IL opcode。

C# 終於有 union types——.NET 11 把 discriminated union 收進型別系統

C# 缺 discriminated union 缺了二十年。要表達「一個值是 A 或 B 或 C,而且這三者彼此沒有繼承關係」這種需求——HTTP 回應是 Ok<T>Error、解析結果是 NumberStringNull——過去只有三條難看的路:開一個 abstract base class 強行讓不相干的型別共用祖先、用 object 加手動 tag 然後到處 cast、或裝 OneOf 這個 NuGet 套件。.NET 11 preview 2 起,C# 15 把 union 收進語言本身。底下拆四層看它怎麼組起來:一行 union 宣告 compiler 看到什麼、生成的 struct 長什麼樣、switch 的窮舉檢查從哪來、value type 的 boxing 怎麼用 TryGetValue<T>() 繞過。先講清楚:這跟 5/22 那篇 unsafe / memory-safety 完全是兩件事,union 是純粹的型別系統與編譯期糖衣,runtime 一行沒動。

這篇假設你已經知道 F# 有 discriminated union、Rust 有 enum、TypeScript 有 tagged union——C# 是這個俱樂部裡最晚到的大型靜態語言。重點不在「終於有了」這個情緒,而在 C# team 選的實作路徑:他們沒有在 CLR 裡加 native sum type(那會是多年的 runtime 工程與相容性風險),而是讓 compiler 把 union 降解成一個你自己也能手寫的 struct。這個選擇有具體後果——可以 target net8.0、net48 這些老 runtime;預設會 box value type;而你想要的話,可以接管生成、自己寫一個不 box 的版本。下面第一個互動工具就是讓你親手挑一個 case,看 compiler 把它放進哪個欄位、哪個 constructor 被呼叫、switch 的哪一條 arm 接住它。

點任一個 case 方塊,看它流進 struct 的哪個 constructor、被哪條 switch arm 接住 · 3 個 case

case records generated struct switch arm Windows (string Version) Linux (string Distro, …) MacOS (string Name, int) [Union] struct SupportedOS : IUnion object? Value = null 3 ctor overloads + implicit conv. ctor(MacOS) Windows w => $"Windows {w.Version}" Linux l => $"{l.Distro} {l.Version}" MacOS m => $"MacOS {m.Name}" implicit conversion 把 record 包進 Value——switch 再用 pattern match 拆回來

selected case

Windows · 走過的路

SupportedOS os = new Windows("11");,compiler 看到右邊型別是 union 的成員之一,套用生成的 implicit conversion——呼叫 SupportedOS(Windows value) 這個 ctor,把 Windows 實例 box 進 object? Value

switch 端os switch { Windows w => … } 這條 arm 用 type pattern 對 Valueis Windows 測試,命中後 unbox 成 w。其他兩條 arm 不會被執行。

Linux · 走過的路

new Linux("Arch", "rolling") 命中的是 SupportedOS(Linux value) ctor。三個 ctor overload 只差參數型別,靠 overload resolution 選對——這也是為什麼 union 的 case 型別必須兩兩相異,否則 ctor 簽章撞號。

關鍵Value 在任何時刻只持有「一個」case 的實例,這就是 discriminated union 的 sum-type 語意——不是三個欄位都在,而是三選一。

MacOS · 走過的路

new MacOS("Tahoe", 25)SupportedOS(MacOS value)。注意 MacOS 含一個 int Version——但整個 MacOS record 是 reference type,box 的是 record 本身、不是裡面的 int。真正會踩到 boxing 痛點的是 union IntOrBool(int, bool) 這種「case 本身就是 value type」的情形,下面第三節處理。

switch 端MacOS m => … 命中。三條 arm 窮舉了所有 case,compiler 不要求也不建議 _ => catch-all。

左欄三個 record 是 union 的成員型別;中間是 compiler 生成的 struct SupportedOS : IUnion,唯一的狀態是 object? Value;右欄是 switch 的三條 arm。點不同 case 看 Value 持有什麼、哪個 ctor 被呼叫。

左欄三個 record 是 union 的成員型別;中間是 compiler 生成的 struct Supported…

C# 15 union 把 case 包進 struct 的 object? Value;switch 拆回,漏 case 亮 warning。

一行 union 宣告:compiler 看到的東西

宣告語法刻意做得跟 positional record 一樣短。把成員型別列在括號裡,就是一個 union:

// 三個彼此不相干的 case 型別,先各自宣告成 record
public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);

// 一行宣告 union:用 union 當關鍵字,括號裡列出它由哪些型別組成
public union SupportedOS(Windows, Linux, MacOS);

三個 record 之間沒有共同 base class、沒有共同 interface——它們唯一的關聯就是「同時出現在 SupportedOS 的成員列表裡」。這正是 discriminated union 相對於 class hierarchy 的核心差異:sum type 不要求成員共享祖先。WindowsLinuxMacOS 可以是來自三個不同 library、你完全無法修改的型別,照樣能組成一個 union。對照 abstract base class 的做法——你得能改這三個型別、讓它們繼承同一個 abstract class OperatingSystem,這對第三方型別根本辦不到。

建構一個 union 值有兩種寫法,多數時候你只會用到後者:

// 顯式:明確包一層
SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));

// 隱式轉換(慣用):直接賦值 case 型別,compiler 補上轉換
SupportedOS os = new MacOS("Tahoe", 25);

隱式那行才是重點。new MacOS(...) 的型別是 MacOS,但左邊宣告的是 SupportedOS——compiler 看到目標型別是個 union、而 MacOS 是它的成員,就自動套用生成的 implicit conversion operator。這條 implicit conversion 是由生成 struct 上的 [Union] attribute 驅動的;沒有那個 attribute,這行不會編譯。要強調的是這些都發生在 編譯期:IL 裡看到的就是一次普通的 struct ctor 呼叫,沒有任何新的 runtime 機制介入。union 完全是 compiler 的語法降解(lowering),CLR 對「union」這個概念一無所知。

版本與專案設定的門檻很低,但有幾個硬條件。最低要 .NET 11 SDK preview 2(功能在 preview 2 落地、但 preview 4+ 才比較穩,建議用後者);專案檔必須開 <LangVersion>preview</LangVersion>,因為這是 C# 15 的 preview 功能。值得注意的是:你可以用新 SDK 編譯、卻 target 舊 runtime——net8.0、甚至 net48 都行。原因正是 union 是純編譯期降解:生成的 struct 只用到 object 與一般 ctor,不依賴任何 .NET 11 才有的 runtime 型別。代價是 target 舊框架時,你得自己提供 UnionAttributeIUnion 這兩個 helper 型別的定義(compiler 找不到就自己給),但這只是幾行樣板。

這個「helper 型別可自備」的細節值得多看一眼,因為它揭露了整個功能的依賴邊界:compiler 需要的不是某個 runtime 實作,而是兩個 符號——名為 UnionAttribute 的 attribute 與名為 IUnion 的 interface(帶一個 object? Value)。在 .NET 11 的 BCL 裡它們已內建,但對 net48 這種沒有的框架,你在自己的 assembly 補上同名同形狀的定義,compiler 一樣認得、一樣生成正確的降解。這跟 C# 過去幾個「pattern-based」功能(如 foreach 只要型別有 GetEnumeratorawait 只要有 GetAwaiter)一脈相承:語言層綁的是形狀而非具體型別。對要在多 target 框架的 library 裡用 union 的人,這意味著你不被 .NET 11 綁死——但也意味著你得搞清楚自己 target 的每個框架是否需要補這段 polyfill。

窮舉檢查:switch 為什麼能少掉 _ => catch-all

union 真正改變日常寫法的地方在 switch 的消費端。因為 compiler 知道 SupportedOS 只可能是這三個 case 之一,它能在編譯期判斷你的 switch 是不是窮舉(exhaustive)了所有可能:

string GetDescription(SupportedOS os) => os switch
{
    Windows windows => $"Windows {windows.Version}",
    Linux linux     => $"{linux.Distro} {linux.Version}",
    MacOS macOS     => $"MacOS {macOS.Name} ({macOS.Version})",
}; // 不需要 _ => 這條 catch-all

三條 arm 覆蓋了全部三個 case,於是不需要 _ => 那條 default arm。對照過去用 object 加 type pattern 的寫法——compiler 不知道 object 還可能是什麼,永遠逼你補一條 _ => throw new InvalidOperationException() 收尾,那條 arm 在邏輯上是 dead code 卻又不能刪:它存在的唯一理由是讓 compiler 閉嘴,但它永遠不會被執行(如果其他 arm 真的覆蓋了所有實際型別),於是它也永遠不會被測試覆蓋到——是那種「明明是 dead code、靜態分析卻無法替你證明、code review 看到也只能聳肩放過」的雜訊。union 把這條 dead arm 消掉了,而且更重要的是反過來:如果你 漏掉 一個 case,compiler 會發出 warning:

warning: The switch expression does not handle all possible
values of its input type (it is not exhaustive).

這是 union 相對於「object + 手動 tag」最值錢的一條保證。設想你日後給 SupportedOS 加了第四個 case FreeBSD——所有沒處理 FreeBSDswitch 會立刻在編譯時亮 warning,逐一指出哪裡要補。用 object 加 tag 的舊寫法,加 case 不會有任何編譯期提醒,只能靠 runtime 撞到 default arm 才發現漏處理。要留意的措辭:compiler 對漏 case 發的是 warning 不是 error——預設不會擋編譯。團隊若想要硬性窮舉,得把這條 warning 用 <WarningsAsErrors> 升級成 error,否則它跟其他 warning 一樣容易被忽略。

下面這個工具把「從 union 關鍵字到窮舉 warning」這條 compiler pipeline 拆成四個階段。每一階段點開看它負責什麼、不負責什麼——窮舉檢查發生在最後一步,靠的是前面三步建立起的「這個型別只有 N 個 case」這項編譯期知識。

點任一階段看它的責任邊界 · 4 個 compiler 階段

union 關鍵字到窮舉 warning:四個 compiler 階段

union 全部發生在 compiler——runtime 看到的只是普通 struct 與 ctor 呼叫 ① parse 辨識 union 關鍵字 記下 N 個成員型別 ② generate struct : IUnion Value + ctor + [Union] ③ lower implicit conversion = new MacOS(…) → ctor ④ check switch arm 覆蓋全部 case? 缺 → 窮舉 warning ①②③ 是降解(lowering),④ 是 flow analysis——窮舉檢查靠的是 ① 記下的「只有 N 個 case」這項知識

selected stage

① parse · 責任邊界

public union SupportedOS(Windows, Linux, MacOS); 解析成一個 union 宣告,登記成員型別清單。這一步建立了後續所有檢查的根據:SupportedOS 恰好有三個 case」

不負責:生成任何程式碼、檢查 switch。它只是把語法變成 symbol table 上的事實。

② generate · 責任邊界

合成出 struct SupportedOS : IUnion——一個 object? Value 屬性、每個 case 一個 ctor overload、貼上 [Union] attribute。這是你「本來可以自己手寫」的那段樣板,compiler 替你寫了。

不負責:決定 Value 怎麼存(box 與否)。預設實作就是 box;要繞過得自己接管(見下一節)。

③ lower · 責任邊界

SupportedOS os = new MacOS(...); 這種隱式轉換改寫成對 SupportedOS(MacOS) ctor 的呼叫。這條 implicit conversion 是 [Union] attribute 授權的;attribute 不在,轉換不存在,這行編不過。

不負責:runtime 行為——降解後就是一般 IL,CLR 不知道 union 是什麼。

④ check · 責任邊界

對每個消費 union 的 switch 做 flow analysis:arm 覆蓋了全部 case 嗎?覆蓋了就不要求 _ =>;漏了就發 not exhaustive warning。這一步把 ① 的「只有 N 個 case」兌現成編譯期保證。

不負責:強制你補齊——它發的是 warning 不是 error。要硬性窮舉得自己升 WarningsAsErrors

四個階段裡前三個是降解、第四個是 flow analysis。窮舉檢查能成立的唯一理由是 ① 已經把「這個型別只有 N 個 case」記進 compiler——這是 union 跟 object + 手動 tag 的根本分野。

四個階段裡前三個是降解、第四個是 flow analysis

union 四降解階段(parse、generate struct、lower、窮舉 check)全在 compiler,CLR 對 union 概念一無所知。

boxing:預設會 box,TryGetValue<T>() 怎麼繞過

生成的 struct 把每個 case 存進 object? Value。當 case 是 reference type(像前面的 record)時這沒問題——record 本來就在 heap 上,Value 只是存一個 reference。但當 case 是 value type 時,存進 object 就觸發 boxing:value 被複製到 heap、配一塊記憶體、Value 指過去。最典型的例子是 union IntOrBool(int, bool)——intbool 都是 value type,預設實作每次建構都 box 一次、每次 switch 取值再 unbox 一次。在 hot path 上這是實打實的 heap allocation 與 GC 壓力。

C# team 對這個取捨的回答是:預設求方便(一律 box,實作最簡單),但把 struct 開放給你接管。union 降解出的那個 struct 你可以不讓 compiler 生成、改自己手寫——只要實作 IUnion、貼 [Union],再提供 TryGetValue<T>() overload,compiler 在 switch 時就會自動改用你的 TryGetValue 而不是 box 用的 Value 屬性。手寫版用一個 discriminator 欄位加一塊共用 storage,徹底避開 heap:

[Union]
public struct IntOrBool : IUnion
{
    private readonly bool _isBool;
    private readonly int _value;   // int 與 bool 共用這塊 stack storage

    public IntOrBool(int value)  { _isBool = false; _value = value; }
    public IntOrBool(bool value) { _isBool = true;  _value = value ? 1 : 0; }

    // compiler 在 switch 裡自動挑對應 T 的 overload,避開 boxing
    public bool TryGetValue(out int value)
    {
        value = _value;
        return !_isBool;          // 只有當前真的是 int 時回 true
    }
    public bool TryGetValue(out bool value)
    {
        value = _isBool && _value is 1;
        return _isBool;
    }

    // fallback:仍提供 Value,但走到這裡才會 box
    public object Value => _isBool ? _value is 1 : _value;
}

機制的關鍵在最後一段註解:Value 屬性還在(IUnion 要求它),但只有「不走 switch、直接讀 Value」時才會 box。當你在 switch 裡寫 case int i => …,compiler 偵測到有 TryGetValue(out int),就生成「呼叫 TryGetValue、成功就用 out 出來的 i」而不是「讀 Value 再 unbox」。整個 hot path 的值都待在 stack 上,零 allocation。代價是你得自己維護 discriminator 與 storage 的正確性——intbool 剛好都塞得進一個 int 欄位,換成 intdouble 你就得想清楚 storage 布局(可能要用 explicit layout 或兩個欄位)。下表把預設與手寫兩種實作在「建構一次 + switch 一次」這條路徑上的差別擺在一起:

同一個 IntOrBool,預設生成(box)對上手寫 TryGetValue(不 box),比「建構一次 + switch 取值一次」這條路徑。
面向 預設生成 · object? Value 手寫 · TryGetValue<T>
storageobject(heap 指標)int _value + bool _isBool(stack)
建構 int casebox → heap alloc寫欄位 · 0 alloc
switch 取值unboxTryGetValue out · 0 alloc
GC 壓力每值一次
switch 用哪個成員Value 屬性TryGetValue(compiler 自動挑)
作者負擔零——compiler 全包自己維護 discriminator + layout
適用場景一般程式碼、case 是 reference typehot path、case 是 value type
取捨很乾淨:預設零作者負擔但 value type 會 box;手寫零 allocation 但要自己管 storage。多數 union(case 是 record / class)落在第二欄沒問題那側,TryGetValue 只在「case 本身是 value type 且在 hot path」時才值得寫。

取捨很乾淨:預設零作者負擔但 value type 會 box;手寫零 allocation 但要自己管 storage

value type case 預設每次 box(24 B heap);手寫 TryGetValue 讓值留在 stack,hot path 配置量為零。

這個設計的優雅之處在於 switch 端的程式碼完全不變——不管底下是預設 box 版還是手寫 TryGetValue 版,你寫的 os switch { int i => …, bool b => … } 一字不差。compiler 在編譯期看實作有沒有 TryGetValue 決定生成哪種取值路徑。這代表你可以先用預設版把功能寫對,profile 之後發現某個 union 在 hot path 上 box 成本顯著,再單獨把那個 union 換成手寫版,而所有消費端一行都不用改。這種「介面穩定、實作可替換」的性質,正是把 union 做成 struct + IUnion 而不是 CLR native type 換來的彈性。

boxing 的代價要量化才有感覺。每次 box 一個 value type,CLR 在 heap 上配一個 object——在 64-bit 上一個 boxed int 約 24 bytes(object header 16 bytes + 對齊後的 payload)。單看一次不痛,但 hot path 的特徵就是「同一段程式碼一秒跑幾十萬次」。假設一個解析器把每個 token 包成 union Token(Number, Op, Paren)、其中 Numberdouble,每秒解析一百萬個 token,預設 box 版就是每秒一百萬次 heap allocation——直接灌進 Gen 0,把 GC 觸發頻率往上推,p99 延遲被 GC pause 拉長。手寫 TryGetValue 版這個數字是零。下面這個工具讓你拖動「每秒建構的 union 值數量」,看兩種實作的 heap allocation 與粗估 Gen 0 GC 觸發次數怎麼隨之發散——刻意用同一條軸,凸顯「不是慢一點,是一邊有、一邊完全沒有」。

拖動「每秒建構的 union 值」,比預設 box 版與手寫 TryGetValue 版的 heap 配置量

1,000,000

預設生成 · object? Value(box)

22.9MB / s
~ 2,930 次 Gen 0 GC / s
每值 24 B(boxed value type)

手寫 · TryGetValue<T>(不 box)

0MB / s
0 次 Gen 0 GC / s
值留在 stack / 內嵌 struct
預設版:每個 value-type case 建構時 box 一次,配 ~24 B(64-bit:16 B object header + 8 B payload)。手寫 TryGetValue 版值留在 struct 內嵌 storage,配置量恆為零。Gen 0 GC 次數以 ~8 MiB 名目預算粗估——實際視 GC 模式與存活率而定,這裡只為呈現量級差。

預設版:每個 value-type case 建構時 box 一次,配 ~24 B(64-bit:16 B objec…

每秒百萬個 value-type union:預設 box 版需 22.9 MB heap 與 2930 次 Gen 0 GC;手寫版零 allocation。

把 slider 拉到一千萬,預設版每秒配掉幾百 MB、觸發數萬次 Gen 0 回收,手寫版仍是平的零。這正是「預設方便、必要時可接管」這個設計哲學的價值落點:絕大多數 union 的 case 是 record 或 class(reference type),Value 只存 reference、根本不 box,預設版就是對的;只有「case 是 value type 且 union 落在每秒百萬級的 hot path」這個窄但真實的交集裡,才值得花力氣寫手寫版。換句話說,C# team 沒有為了照顧 1% 的 hot path 而讓 99% 的一般程式碼承擔複雜度——預設求對、求簡單,把效能逃生口留給真的需要的人。

放回脈絡:跟 OneOf、F#、Rust、TypeScript 比

C# 不是發明 discriminated union,是補一門大型靜態語言該有卻遲到的特性。把同一個「ResultOk<T>Error」的需求放到各語言看,能看清 C# 這版實作的定位——它最像 OneOf(因為都是「在沒有 native sum type 的語言上用既有機制模擬」),但靠 compiler 內建拿到了 OneOf 拿不到的窮舉檢查與隱式轉換。下面切到各語言的對應寫法:

切換頁籤比較同一個 Result 在五種語言/方案的寫法 · 5 個頁籤

內建語言特性。窮舉由 compiler 保證、隱式轉換內建、可選擇手寫 struct 避開 boxing。case 型別無需共享祖先。

public union Result(Ok, Error);
public record Ok(string Value);
public record Error(int Code);

string Show(Result r) => r switch
{
    Ok ok       => ok.Value,
    Error err   => $"err {err.Code}",
};  // 漏 case → compiler warning

C# 過去最接近的方案:純 library,用泛型 OneOf<T0, T1>.Match()。沒有 compiler 層的窮舉檢查——.Match() 要求你傳齊每個 handler 是靠泛型方法簽章逼出來的,不是 flow analysis。新版 OneOf 可以實作 IUnion + [Union] 來借用內建 switch。

OneOf<Ok, Error> r = new Ok("hi");

string s = r.Match(
    ok  => ok.Value,
    err => $"err {err.Code}"
);  // handler 漏傳 → 編不過,但靠的是方法簽章

F# 自誕生就有 discriminated union,是 .NET 生態裡 union 的原生範本。語法最簡潔,match 內建窮舉檢查。C# 15 的 union 某種意義上是把 F# 這個能力搬到 C# 側、讓兩語言能更自然互通。

type Result =
    | Ok of string
    | Error of int

let show r =
    match r with
    | Ok v    -> v
    | Error c -> sprintf "err %d" c
    // 漏 case → compiler warning

Rust 的 enum 就是 sum type,且是 value type、不 box、layout 由 compiler 最佳化(niche optimization)。match 窮舉是 error 不是 warning——這是 C# 與 Rust 最大的態度差:Rust 強制窮舉,C# 預設只警告。

enum Result {
    Ok(String),
    Error(i32),
}

fn show(r: Result) -> String {
    match r {
        Result::Ok(v)    => v,
        Result::Error(c) => format!("err {c}"),
    }  // 漏 case → compile error
}

TypeScript 的 tagged / discriminated union 靠結構型別加一個共同的 literal tag 欄位。窮舉檢查靠 never 這個 trick——把漏掉的 case 餵給一個 never 參數的函式,漏了就型別不符。沒有 runtime 概念,純編譯期。

type Result =
  | { tag: "ok"; value: string }
  | { tag: "error"; code: number };

function show(r: Result): string {
  switch (r.tag) {
    case "ok":    return r.value;
    case "error": return `err ${r.code}`;
    // 漏 case → 透過 never 檢查報錯
  }
}

互動圖表

C# 15 compiler 窮舉超越 OneOf;Rust 漏 case 是 error,C# 只有 warning;F# DU 是 .NET 最早原生實作。

五種寫法擺在一起,C# 15 的座標就清楚了。它在「窮舉嚴格度」上比 Rust 鬆(warning 而非 error),這跟 C# 一向把 source compatibility 看得很重有關——把漏 case 設成 error 會讓「加一個 case」變成 breaking change,弄壞所有下游 switch。Rust 的 match 漏 case 是硬性 compile error,這逼得 Rust 加 enum variant 本身就是一個需要全 codebase 配合的動作;C# 選 warning,是把這個取捨的決定權交還給團隊,代價是預設下漏處理只會默默累積成 warning 噪音。它在「value type 效率」上靠手寫 TryGetValue 才能追上 Rust 的零成本(Rust 的 enum 預設就是不 box 的 value type、layout 由 compiler 做 niche optimization 壓到最小),但給了 reference-type case 預設零負擔的方便。它相對 OneOf 的決定性優勢是 compiler 內建的窮舉 flow analysis 與隱式轉換——OneOf 的 .Match() 要逼你寫全 handler、語法也更囉嗦,而且它的窮舉是靠泛型方法簽章硬湊的、不是真正的 flow analysis,型別推斷的錯誤訊息往往很難讀。而它跟 F# 的關係最微妙:F# 早就有 DU,C# 補上後兩者在 .NET 上能更自然地共享 union 型別、互相消費——一個 F# library 暴露的 DU,未來 C# 端有機會用內建 switch 而非笨拙的 Tag 屬性去拆解。

對下週要寫 code 的人,決策面是這樣:新專案若已在 .NET 11 preview 軌道、且有「值是數種不相干型別之一」的建模需求(解析器的 token、API 的 polymorphic response、狀態機的 state),union 直接取代你過去會開的 OneOf<...> 或 abstract base class——少一個 NuGet 依賴、多一層 compiler 窮舉保證。已在生產的專案先別急:這是 C# 15 preview 功能、要開 LangVersion preview,preview 功能的語法在正式版前仍可能變動,拿來探路可以、押在關鍵路徑上要謹慎。真正要做效能決策的只有一種情形——union 的 case 是 value type 且落在 hot path,那時才需要從預設版升級到手寫 TryGetValue 版,而升級不影響任何消費端程式碼。

What this enables:C# 第一次能在語言層面表達「一個值是這幾種不相干型別之一」,並把 compiler 的窮舉檢查綁上去——慣用的 Result<T> / Option<T> 不再需要 abstract base class 或 OneOf 套件,新增 case 時所有沒處理它的 switch 會立刻亮 warning,而這一切只是一個你自己也能手寫的 struct : IUnion 的薄包裝,連舊 runtime 都能 target。