data.mdx.frontmatter.hero_image

Czy na takie Unie w C# czekaliśmy?

2026-06-26 | .NET | bd90

Człowiek przez całe życie powiększa swój toolbox. Jako programiści .NET przez ostatnią dekadę patrzyliśmy z lekką zazdrością na sąsiada z drugiej strony płotu, czyli F#, który od zawsze miał discriminated unions. My w międzyczasie sklejaliśmy namiastki: hierarchie klas, biblioteki jak OneOf, własne Result-y. Działało, ale zawsze czuło się, że to proteza.

No i się doczekaliśmy. C# 15 razem z .NET 11 przynosi unie wbudowane w język. Internet od razu podzielił się na dwa obozy: jedni krzyczą, że to rewolucja, drudzy, że za późno i nie tak. A ja, mój drogi czytelniku, chciałbym usiąść gdzieś pośrodku i uczciwie odpowiedzieć na pytanie z tytułu. Bez hype-u i bez hejtu. Najpierw zobaczymy czym właściwie jest unia, potem za co warto ją pokochać, a na koniec policzymy rachunek, który płacimy za doklejenie tego feature-a do dojrzałego języka. Zaczynajmy!

Czym właściwie jest unia?

Zacznijmy od podstaw, bo słowo "unia" jest mocno przeładowane. Na potrzeby tego artykułu umówmy się na taką definicję:

Unia to wartość, która jest DOKŁADNIE jednym z elementów pewnego zamkniętego zbioru typów.

Słowo klucz to "zamknięty" (closed set). Lista przypadków jest skończona i znana z góry. Nikt z zewnątrz nie dorzuci sobie nowego wariantu po cichu.

Tu zaczyna się robić ciekawie, bo unie nie są jednym bytem. To cała rodzina, a najlepiej rozróżnić jej członków po jednym pytaniu: skąd właściwie wiadomo, który to przypadek? Ta odpowiedź to dyskryminator.

Pierwszy wariant to type union (czasem zwany untagged). Dyskryminatorem jest sam TYP wartości. Klasyczny przykład z TypeScript-a:

type Id = string | number;

Etykietą jest tu po prostu string albo number. Nie ma żadnego dodatkowego znacznika, sam typ wartości mówi nam wszystko.

Drugi wariant to discriminated union (tagged union, sum type, ADT), znany właśnie z F#. Tutaj dyskryminatorem jest jawny TAG, a warianty są deklarowane WEWNĄTRZ unii i nie istnieją jako samodzielne typy:

type Temperature =
    | Celsius of float
    | Fahrenheit of float

Mamy Celsius i Fahrenheit, oba niosą float, a mimo to są rozróżnialne, bo każdy ma swój tag.

I tu pojawia się prosty test, który pozwala odróżnić te dwa światy: czy zniesiesz dwa przypadki o tym samym typie? W discriminated union jak najbardziej, Celsius of float i Fahrenheit of float żyją obok siebie bez problemu. W type union nie ma takiej możliwości, bo float | float to po prostu float. Jeśli chcesz rozróżnić takie przypadki, musisz zrobić z nich osobne typy.

Na koniec warto zapamiętać jeden pomost, bo za chwilę okaże się kluczowy: discriminated union zawsze da się wyrazić przez type union, jeśli tylko jako przypadków użyjesz świeżych, osobnych typów. Zamiast taga Celsius tworzysz po prostu typ Celsius. Trzymaj tę myśl z tyłu głowy.

Jak wygląda to w C# 15

Zanim wejdziemy w kod, mała tabelka z kalendarza, bo to świeżynka. Unie jadą w pakiecie C# 15 / .NET 11. GA planowane jest na listopad 2026, ale pobawić się można już teraz: preview jest dostępne od .NET 11 Preview 2 (kwiecień 2026), schowane za flagą:

<LangVersion>preview</LangVersion>

Samo wsparcie w runtime, czyli UnionAttribute i IUnion, doszło od Preview 5. Część proposalu wciąż czeka na implementację, między innymi tak zwane union member providers, czyli możliwość zadeklarowania składowych unii na osobnym typie niż sama unia (przydatne, gdy chcesz zrobić unię z typów, których nie kontrolujesz). Innymi słowy, to co dziś widzimy to nie jest jeszcze pełny obraz.

Najważniejsza decyzja projektowa: C# wybrał type union. Komponuje istniejące typy, zamiast deklarować warianty wewnątrz unii. I to był świadomy wybór, bo wcześniejszy proposal ze stycznia 2026 szedł w stronę discriminated. Pamiętasz pomost z poprzedniej sekcji? Właśnie z niego skorzystano: skoro discriminated da się wyrazić przez type union ze świeżymi typami, to zespół powiedział "okej, dajmy type union, a tagi zrobicie sobie sami przez osobne typy".

Powiem szczerze, że sam nie jestem hurraoptymistą tego kierunku. Skoro tagi robimy przez osobne typy, to przy bogatszej domenie łatwo wpaść w pewną nadmiarowość, czyli stos malutkich typów-opakowań, które istnieją głównie po to, żeby się od siebie różnić. Rozumiem ten trade off i jeszcze do niego wrócę w sekcji o rachunku, bo ma też swoją jasną stronę. Gdzieś z tyłu głowy zostaje mi jednak myśl, że mogło być zwięźlej.

Zobaczmy jak to wygląda. Najpierw definiujemy przypadki jako zwykłe record class-y:

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

A następnie sklejamy je w unię nowym słowem kluczowym union:

public union Pet(Cat, Dog, Bird);

I to tyle. Od teraz mamy niejawne konwersje z każdego typu-przypadku do unii, więc przypisanie wygląda zupełnie naturalnie:

Pet pet = new Cat("Filemon");

Najprzyjemniejsza część to pattern matching, który "rozpakowuje" zawartość unii. Co istotne, wzorce stosują się do tego co w środku, a nie do samego wrappera:

string describe = pet switch
{
    Cat c => $"Kot o imieniu {c.Name}",
    Dog d => $"Pies o imieniu {d.Name}",
    Bird b => $"Ptak o imieniu {b.Name}",
};

A co siedzi pod spodem? Nic magicznego. To record struct trzymający jedno pole typu object? o nazwie Value. Zapamiętaj sobie ten object?, bo wrócimy do niego w sekcji o rachunku i okaże się źródłem dwóch z naszych problemów.

Na koniec szersza perspektywa, żeby nie budować fałszywych oczekiwań. Unie to tak naprawdę tylko 1/3 większej historii o wyczerpalności w C#. Pozostałe dwie trzecie to closed hierarchies (modyfikator closed) oraz closed enums. Obie wciąż są aktywnymi propozycjami i nie ma ich w tym wydaniu. Czyli dostajemy ważny kawałek układanki, ale nie całą układankę.

Za co to pokochać

Teraz część, w której robi się ciepło na sercu. Bo jak już przebrniesz przez składnię, to okazuje się, że unie rozwiązują realne, codzienne bóle.

Pierwszy i moim zdaniem najważniejszy: modelowanie domeny. To jest dokładnie ta idea, którą Scott Wlaschin w "Domain Modeling Made Functional" opisuje jako "making illegal states unrepresentable". Czyli takie zaprojektowanie typów, żeby nieprawidłowy stan po prostu nie dał się wyrazić w kodzie.

Wyobraźmy sobie wynik płatności. Klasyczne podejście z jednym Result<T> spłaszcza wszystko do dwóch stanów: sukces albo jeden generyczny Error. A przecież "płatność odrzucona przez bank" to coś zupełnie innego niż "wykryto fraud" czy "padła sieć". Z unią modelujemy każdy tryb porażki jako OSOBNY typ z własnym, sensownym ładunkiem:

public record class Approved(decimal Amount, string TransactionId);
public record class Declined(string Reason);
public record class Fraud(string CaseNumber);
public record class NetworkError(int RetryAfterSeconds);

public union PaymentOutcome(Approved, Declined, Fraud, NetworkError);

Spójrz jak ładnie to czytasz. Każdy przypadek niesie dokładnie te dane, które mają sens tylko dla niego. RetryAfterSeconds istnieje wyłącznie przy NetworkError, a CaseNumber wyłącznie przy Fraud. Nie ma żadnego worka z nullami "na wszelki wypadek".

Drugi powód to wisienka na torcie: wyczerpalność na poziomie kompilatora. Załóżmy, że ktoś dorzuca piąty wariant, na przykład Pending. Od tej chwili każdy switch, który go nie obsługuje, zaczyna protestować:

decimal AmountToBook(PaymentOutcome outcome) => outcome switch
{
    Approved a => a.Amount,
    Declined => 0,
    Fraud => 0,
    NetworkError => 0,
    // dorzucasz Pending do unii -> kompilator wytknie ten switch,
    // dopóki nie obsłużysz nowego przypadku
};

Lubię o tym myśleć jak o "compile-time unit testach". Kompilator za darmo pilnuje, żebyś nigdzie nie zapomniał o nowym przypadku.

I tu czeka mała niespodzianka, na którą sam się nadziałem, gdy pisałem ten artykuł i sprawdzałem feature na żywo na .NET 11 preview. Byłem przekonany, że niewyczerpany switch to twardy błąd kompilacji, takie "nie ma mowy, popraw to". A dostałem zwykły warning CS8509. Kod się buduje, program się uruchamia, świat się nie kończy. Kompilator owszem, jest miły i mówi wprost czego brakuje, i to konkretnie po nazwie ("nie jest uwzględniony wzorzec Pending"), ale domyślnie jest to tylko ostrzeżenie, a nie blokada.

Żeby zamienić to w prawdziwy "compile-time unit test", musisz sam podnieść tej diagnostyce rangę, na przykład przez <WarningsAsErrors>CS8509</WarningsAsErrors> w pliku projektu albo globalne TreatWarningsAsErrors. Dopiero wtedy dostajesz to twarde "nie skompiluje się". Sprawdziłem na własne oczy: ten sam kod, który wcześniej przechodził z ostrzeżeniem, po włączeniu flagi wywala się błędem. Dla mnie i tak idzie to do ustawień domyślnych każdego nowego projektu, ale dobrze wiedzieć, że to świadoma decyzja, a nie prezent z pudełka.

Zaznaczę jeszcze jedno, żeby było uczciwie. Klasyczne hierarchie klas dostają dokładnie ten sam CS8509. Różnica nie leży więc w wadze diagnostyki, bo w obu przypadkach to ostrzeżenie, tylko w jej jakości. Dla unii kompilator zna zamknięty zbiór i wytyka brakujący wariant po nazwie. Dla otwartej hierarchii potrafi jedynie zasugerować gałąź _, bo nie wie, czy ktoś nie dorzuci kolejnej klasy gdzieś indziej. To wciąż realna przewaga unii, po prostu subtelniejsza niż "błąd kontra warning".

Jeśli ten sposób myślenia o encjach i niezmiennikach Cię kręci, to pisałem już o ewolucji w tę stronę w tekście Trzy sposoby modelowania encji. Tam dochodziłem do encji typu "Always Valid", pilnując niezmienników wyjątkiem. Unie to naturalny kolejny krok tej samej drogi, tyle że teraz to TYP pilnuje poprawności, a nie rzucony w locie InvalidOperationException. Sama idea modelowania domeny po funkcyjnemu wróciła też u mnie przy okazji warsztatów opisanych w System Rezerwacji w 5 minut.

Rachunek za doklejenie do dojrzałego języka

No dobra, dość zachwytów. Tytułowy znak zapytania nie wziął się znikąd. Unie w C# nie powstały na czystej kartce jak w F#. Doklejono je do języka, który ma już nulla, garbage collector i dwadzieścia lat historii. I za to się płaci. Przejdźmy po rachunku punkt po punkcie.

Po pierwsze, brak toolingu do Railway-Oriented Programming. Tu krótka dygresja, bo to temat na osobny artykuł (i obiecuję, że taki kiedyś popełnię). ROP w skrócie to styl, w którym łańcuch operacji jadących "po torach" automatycznie przeskakuje na tor błędu, gdy tylko coś pójdzie nie tak, a Ty nie musisz po każdym kroku ręcznie sprawdzać czy się udało. Żeby to działało, potrzebujesz operatorów typu Map i Bind oraz jakiegoś sposobu na propagację błędu. I tego unie z pudełka NIE dają. Union daje Ci KSZTAŁT, czyli "ta wartość jest jednym z tych przypadków". Komponowalność, czyli sklejanie wielu takich kroków w łańcuch, musisz dokupić osobno: albo sięgasz po LanguageExt czy FluentResults, albo dopisujesz sobie własną monadyczną hydraulikę, czyli te wszystkie Map, Bind i operatory propagacji błędu. Innymi słowy, dostajesz solidny fundament, ale jak chcesz pełne ROP, to resztę domu budujesz sam.

Po drugie, boxing. Pamiętasz to nieszczęsne object? Value spod maski? Otóż kiedy do unii wkładasz typ wartościowy, on ląduje właśnie w tym object, czyli zostaje zboksowany. Dla Pet-a z klasami nikt tego nie zauważy. Ale Result<int> na gorącej ścieżce, wołany milion razy na sekundę, to milion alokacji i niezła presja na GC. Da się to obejść, pisząc ręcznie własny non-boxing [Union] record struct z wzorcem HasValue / TryGetValue:

[Union]
public readonly partial record struct IntResult
{
    public bool HasValue { get; }

    public bool TryGetValue(out int value)
    {
        value = _value;
        return HasValue;
    }
}

Mechanizm jest prosty: trzymasz wartość bezpośrednio w polu odpowiedniego typu, zamiast wrzucać ją do object. Tyle że to już Twoja robota, nie prezent od kompilatora.

Po trzecie, dyskryminator = typ. Wracamy do tego, co ustaliliśmy na początku. Skoro C# wybrał type union, to nie zniesiesz dwóch przypadków o tym samym typie. Chcesz mieć w jednej unii dwie różne kwoty, na przykład cenę i rabat, oba jako decimal? Nie da rady, musisz zrobić z nich osobne typy. I tu jest ciekawy paradoks: w DDD to często ZALETA, bo i tak chcesz mieć value object Price zamiast gołego decimal, o czym pisałem przy okazji EF Core i złożonych typów danych. Ale gdy migrujesz istniejący, prosty model albo robisz coś naprawdę banalnego, to bywa upierdliwym tarciem.

Po czwarte, cień nulla. Tego nie dało się uniknąć, doklejając unie do języka z nullem. Struct union w stanie default ma Value równe null. Pattern matching działa na zawartości Value, a nie na samym wrapperze, więc gdy ktoś przekaże Ci default(Pet), żadna z gałęzi Cat / Dog / Bird się nie złapie. Co więcej, sprawdzenie pet is Pet w ogóle się nie kompiluje, dostajesz error CS8121 ("wyrażenie typu Pet nie może być obsługiwane przez wzorzec typu Pet"). Pattern matchingiem masz odpytywać unię o jej przypadki, a nie o nią samą, więc kompilator ucina taki zapis już na starcie. To dokładnie ten rodzaj gotcha, którego w czystym discriminated-union-owym świecie po prostu by nie było.

Cztery punkty, ale zauważ jedną rzecz: żaden z nich nie jest błędem. To wszystko są konsekwencje świadomych wyborów i kontekstu, w jakim ten feature powstawał.

Podsumowanie, czyli czekaliśmy czy nie?

To jak, mój drogi czytelniku, odpowiedzmy wreszcie na tytułowe pytanie. I tak, i nie.

Jeśli modelujesz domenę i marzyłeś o tym, żeby to kompilator pilnował Twoich przypadków zamiast kolejnego testu jednostkowego, to tak, na to czekaliśmy i było warto. Wyczerpalność na etapie kompilacji i możliwość wyrażenia każdego trybu porażki osobnym typem to realna, codzienna wartość, która zmienia sposób w jaki projektujesz kod.

Jeśli natomiast liczyłeś na pełne Railway-Oriented Programming prosto z pudełka albo na zero-alokacyjny Result na najgorętszej ścieżce, to C# dał Ci fundament, ale resztę dobudujesz sam. To nie jest F# i nigdy nie miało nim być. To unie wpisane w język z nullem, GC i długą historią, i jak na te warunki wyszło zaskakująco dobrze.

Warto też pamiętać, że to dopiero 1/3 układanki o wyczerpalności. Prawdziwa moc przyjdzie, gdy dojadą pozostałe dwie trzecie, czyli closed hierarchies i closed enums, oraz gdy domkną się niezaimplementowane jeszcze części samego proposalu unii (jak wspomniane wcześniej union member providers). Dziś dostajemy solidny pierwszy krok, nie metę.

Ja czekałem i nie żałuję. Wchodzę tylko z otwartymi oczami, świadomy rachunku, który przyszło za to zapłacić.

Mam nadzieje, że artykuł się podobał. Jak zwykle do następnego!

Cześć!

Referencje

By Bd90 | 26-06-2026 | .NET