Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Functional Programming Kavramları — #08

C# 15 Union Types ve Closed Hierarchies: Gerçek Sum Type ve Exhaustive Pattern Matching

Mert Özen 15 Kas 2026 15 dk 8 görüntülenme
C# 15 Union Types ve Closed Hierarchies: Gerçek Sum Type ve Exhaustive Pattern Matching

C# 15'in union type'ları birbirinden bağımsız tipleri kapalı bir küme olarak birleştirir, closed modifier'ı ise bir class hiyerarşisinin doğrudan alt tiplerini assembly ile sınırlar; ikisinde de derleyici eksik case'i adıyla söyler. Bookstore'un OrderStatus'unu closed hiyerarşiye, Result ve hata kodlarını union'a çeviriyor, System.Text.Json'un iki yapıyı nasıl serialize ettiğini, TypeScript ile farkları ve public API'de binary breaking change riskini gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki C# 15 kodları .NET 11 SDK (11.0.100) ve net11.0 ile, karşılaştırma için anılan C# 14 çıktıları .NET 10 (SDK 10.0.402) ile, TypeScript örnekleri TypeScript 7.0.2 ile derlenip denenmiştir.

Önce Tahmin Et: Kapalı Hiyerarşiye Dışarıdan Case Eklemek

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) sipariş durumu bu kez C# 15'in closed modifier'ıyla yazılmış ve Bookstore.Ordering assembly'sinde duruyor. Dikkat: Cancelled sealed değil.

namespace Bookstore.Ordering;

public closed record OrderStatus;

public sealed record Pending : OrderStatus;
public sealed record Paid(string PaymentId) : OrderStatus;
public sealed record Shipped(string PaymentId, string TrackingNo) : OrderStatus;
public record Cancelled(string Reason) : OrderStatus;

Başka bir assembly'deki uygulama bu tipleri kullanıyor; bir switch'te Cancelled kolunu unutmuş ve iki yeni record tanımlamış:

using Bookstore.Ordering;

Console.WriteLine(Short(new Paid("PAY-1001")));

static string Short(OrderStatus status) => status switch
{
    Pending => "P",
    Paid => "$",
    Shipped => "S"
};

record Refunded(string Reason) : OrderStatus;

record CancelledByWarehouse(string Reason) : Cancelled(Reason);

Hangi satırlar derlenir? Derleyici unutulan kol için ne der?

Cevap: closed Yalnızca Doğrudan Alt Tipleri Kilitliyor

Program.cs(12,8): error CS9382: 'Refunded': cannot use a closed type 'OrderStatus' from another assembly as a base type.

Refunded derlenmiyor: closed bir class'tan doğrudan türetmek yalnızca onu tanımlayan assembly'de mümkün. Beşinci yazıda abstract record ve private constructor ile kurmaya çalıştığımız ama copy constructor yüzünden tam kapatamadığımız kilit bu. Refunded satırını silince geriye bir uyarı kalıyor:

Program.cs(5,51): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Ordering.Cancelled' is not covered.

C# 14'te aynı switch için derleyici yalnızca "_ pattern'i ele alınmadı" diyebiliyordu; şimdi eksik case'in adını veriyor. CancelledByWarehouse ise sorunsuz derleniyor. Microsoft Learn'deki closed modifier sayfasının da belirttiği gibi kısıt transitif değil: OrderStatus'un doğrudan alt tipleri kilitli, ama sealed olmayan Cancelled'dan herkes türetebilir. Bu exhaustiveness'i bozmuyor, çünkü CancelledByWarehouse da bir Cancelled ve Cancelled kolu onu yakalıyor; ama Cancelled'ın altında da kapalı bir küme istiyorsan onu sealed ya da closed yapman gerekiyor. Case'leri baştan sealed yazmak iyi bir varsayılan. Bu iki assembly'lik örnek ve yazıdaki diğer derleme hatası ya da binary uyumluluk denemeleri, snapshot'taki projeyi derlenebilir tutmak için ayrı deneme projelerinde çalıştırıldı.

closed Hiyerarşi: abstract record'un Eksik Parçası

Bu yazı bir sürüm yazısı: serinin tabanı C# 14 ve .NET 10, buradaki kodlar ise ayrı bir projede (csharp15/, kendi global.json'ı ile .NET 11 SDK'ya sabitlenmiş) derleniyor. C# 15 ve .NET 11, 10 Kasım 2026'da çıktı. Microsoft Learn'deki "What's new in C# 15" sayfası (10 Kasım 2026 tarihli) union type'ları ve closed hierarchy'leri, net11.0 hedefleyen projelerde varsayılan dil sürümüyle ve preview ayarı gerekmeden kullanılabilen özellikler olarak listeliyor; bu yazıdaki her şey o GA (genel kullanıma açılmış kararlı sürüm) SDK ile çalıştırıldı.

Beşinci yazıdaki OrderStatus'u closed ile yeniden yazmak tek kelimelik bir değişiklik:

namespace Bookstore.Fp.CSharp15.Closed;

// Doğrudan case'leri yalnızca bu assembly ekleyebilir; closed, abstract demektir
public closed record OrderStatus;

public sealed record Pending : OrderStatus;
public sealed record Paid(string PaymentId) : OrderStatus;
public sealed record Shipped(string PaymentId, string TrackingNo) : OrderStatus;
public sealed record Cancelled(string Reason) : OrderStatus;

public static class OrderWorkflow
{
    public static string Describe(OrderStatus status) => status switch
    {
        Pending => "pending",
        Paid paid => $"paid with {paid.PaymentId}",
        Shipped shipped => $"shipped with {shipped.TrackingNo}",
        Cancelled cancelled => $"cancelled: {cancelled.Reason}"
    };
}
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-closed
pending
paid with PAY-1001
shipped with TRK-778
cancelled: customer request

Describe'da _ => throw new UnreachableException(...) kolu yok ve derleyici uyarmıyor. Private constructor hilesine de gerek kalmadı; case'ler artık iç içe tanımlanmak zorunda değil. Dokümandaki kurallar kısaca şöyle: closed bir class kendiliğinden abstract, sealed, static ya da açıkça yazılmış abstract ile birlikte kullanılamıyor; yalnızca class'lara (record class dahil) uygulanabiliyor; generic bir case, base class'ta kullanmadığı bir type parameter tanımlayamıyor (aksi hâlde derleyici olası alt tipleri hesaplayamaz). closed bir contextual keyword, yani mevcut kodda closed adlı değişkenler bozulmuyor.

closed'ın asıl değeri, hiyerarşinin bir class hiyerarşisi olarak kalması: case'ler ortak property ve method paylaşabiliyor, with ifadesi, record eşitliği ve positional pattern'ler aynen çalışıyor. Beşinci yazının kodu neredeyse hiç değişmeden exhaustive hâle geldi.

union: Case'leri Kalıtım Olmadan Birleştirmek

Union'ın temel syntax'ını ve Cancellable(Pending, Paid) gibi alt kümeleri beşinci yazının "C# 15 ile: union Type'ları" bölümünde gördük. Burada union'ın kalıtımsız olmasının ne kazandırdığına bakalım. Case tipleri birbirinden habersiz olabilir: int, string, başka bir library'nin tipi, hatta bir generic. Önceki yazıdaki Result'ı bir union olarak yazalım:

public sealed record Ok<T>(T Value);
public sealed record Failed(IReadOnlyList<OrderError> Errors);

public union Result<T>(Ok<T>, Failed)
{
    public Result<TOut> Map<TOut>(Func<T, TOut> map) => this switch
    {
        Ok<T>(var value) => new Ok<TOut>(map(value)),
        Failed failed => failed,
        null => throw new InvalidOperationException("default(Result<T>) has no value.")
    };

    public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> bind) => this switch
    {
        Ok<T>(var value) => bind(value),
        Failed failed => failed,
        null => throw new InvalidOperationException("default(Result<T>) has no value.")
    };
}

Üç ayrıntı var. Failed failed => failed kolunda Failed'dan Result<TOut>'a implicit conversion var; önceki yazıdaki Result<TOut>.Failure(Errors) dönüşümü kendiliğinden oluyor, çünkü Failed her Result<TOut>'un bir case'i. Union'ın gövdesine method ekleyebiliyorsun (ama instance field ya da auto-property ekleyemiyorsun). Ve null kolu: derleyicinin ürettiği union her zaman bir struct ve default değerinin Value'su null. Önceki yazıda Result'ı tam bu yüzden class yapmıştık; union ile aynı soru geri geliyor. Dokümanda class tabanlı bir union'ı [Union] attribute'u ile elle yazmanın yolu da anlatılıyor, ama o zaman derleyicinin ürettiği kısa syntax'tan vazgeçiyorsun.

Hata tarafını da string kodlar yerine bir union olarak modelleyelim; önceki yazının son alıştırması tam bunu soruyordu:

public sealed record EmailInvalid(string Email);
public sealed record BookNotFound(string Isbn);
public sealed record StockInsufficient(string Isbn, int Left);

public union OrderError(EmailInvalid, BookNotFound, StockInsufficient);

public static class OrderErrors
{
    public static int StatusCode(OrderError error) => error switch
    {
        EmailInvalid => 400,
        BookNotFound => 404,
        StockInsufficient => 409
    };

    public static string Message(OrderError error) => error switch
    {
        EmailInvalid(var email) => $"'{email}' is not a valid email address.",
        BookNotFound(var isbn) => $"No book with ISBN {isbn}.",
        StockInsufficient(var isbn, var left) => $"Only {left} left for {isbn}."
    };
}

Önceki yazıdaki StatusByCode dictionary'sinde "stock.insuficient" gibi bir yazım hatası sessizce 400'e düşüyordu. Burada OrderError'a dördüncü bir case eklediğinde StatusCode ve Message'daki switch'ler eksik case'in adını veren CS8509 uyarısı üretiyor; HTTP eşlemesini unutmak artık bir derleyici uyarısı. Kullanırken:

Result<string> result = FindStock(stock, isbn)
    .Bind<int>(left => left >= quantity ? new Ok<int>(left - quantity) : new Failed([new StockInsufficient(isbn, left)]))
    .Map(after => $"{quantity} x {isbn} reserved, {after} left");

Console.WriteLine(result switch
{
    Ok<string>(var text) => $"ok: {text}",
    Failed(var errors) => $"failed: {string.Join(", ", errors.Select(e => $"{OrderErrors.StatusCode(e)} {OrderErrors.Message(e)}"))}",
    null => "default"
});
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-result
ok: 1 x 9780441013593 reserved, 0 left
failed: 409 Only 1 left for 9780441013593.
failed: 404 No book with ISBN 9780261103344.
default: True

Son satır, Result<int> empty = default; için Value'nun null olduğunu gösteriyor. Bind<int>'teki açık type argument'e de dikkat. Lambda bir dalda Ok<int>, diğerinde Failed döndürüyor; bu iki tipin ortak bir tipi yok ve union conversion type inference'a yardım etmiyor. <int>'i silince:

Post08.cs(24,18): error CS0411: The type arguments for method 'Result<int>.Bind<TOut>(Func<int, Result<TOut>>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

Önceki yazıda bu soruna rastlamadık, çünkü Bind'a verdiğimiz lambda'lar dönüş tipi zaten Result<T> olan fonksiyonları çağırıyordu; class tabanlı Result'ın implicit operator'ları da type inference'a yardım etmez. Union ile de çözüm aynı: dönüş tipini yazan küçük fonksiyonlar (FindStock gibi) ya da açık type argument.

TypeScript Discriminated Union ile Karşılaştırma

TypeScript'te sum type yıllardır dilin parçası ve C# 15'in getirdiği iki aracın ikisine de karşılık geliyor. "Kargoya verilmemiş sipariş" alt kümesi TypeScript'te Extract ile mevcut union'dan türetiliyor:

// Yalnızca kargoya verilmemiş siparişler iptal edilebilir
export type Cancellable = Extract<OrderStatus, { kind: "pending" | "paid" }>;

export function cancel(status: Cancellable, reason: string): OrderStatus {
  return { kind: "cancelled", reason };
}
import { cancel, type OrderStatus } from "../src/order-status.ts";

const shipped: OrderStatus = { kind: "shipped", paymentId: "PAY-1001", trackingNo: "TRK-778" };
cancel(shipped, "too late");
$ npx tsc -p tsconfig.errors.json
errors/08-cancellable-errors.ts(4,8): error TS2345: Argument of type '{ readonly kind: "shipped"; readonly paymentId: string; readonly trackingNo: string; }' is not assignable to parameter of type 'Cancellable'.
KonuC# 15 unionC# 15 closed hierarchyTypeScript discriminated union
Case'ler arasında ilişkiyok; case'ler bağımsız tiplerortak base classortak literal property (kind)
Exhaustivenessderleyici, CS8509 uyarısıderleyici, CS8509 uyarısınever ile, hata olarak
Runtime biçimiValue'su object? olan bir structclass hiyerarşisidüz JavaScript object
Alt küme tanımlamakyeni bir union, ör. Cancellableyeni bir closed ara classExtract ile mevcut union'dan
JSON'da case bilgisiyazılmazayarla yazılırkind property'si zaten verinin parçası

En büyük fark son satırda. TypeScript'te discriminator verinin kendisi; C#'ta case bilgisi tipte duruyor ve JSON'a yazılması ayrı bir karar. Bir sonraki bölüm bunun sonuçlarıyla ilgili.

Hangisi Ne Zaman: union, closed ya da Klasik Hiyerarşi?

Üç araç birbirine yakın, ama farklı sorulara cevap veriyor:

  • union: case'ler ortak bir base class'ı paylaşmıyorsa ya da paylaşamıyorsa (başka bir library'nin tipleri, int ve string gibi primitive'ler) ya da aynı tipler farklı kümelerde yer alacaksa (OrderStatus ve Cancellable). Bedeli: struct olduğu için default değeri, value type case'lerde boxing ve JSON'da case bilgisinin kaybolması.
  • closed hierarchy: case'ler ortak veri ya da davranış paylaşıyorsa, record eşitliği ve with ifadesi önemliyse, tipler zaten bir hiyerarşi ise. Mevcut bir abstract record hiyerarşisini exhaustive yapmanın en az değişiklik isteyen yolu bu.
  • Klasik, açık hiyerarşi: başka assembly'lerin yeni case eklemesi gerçekten bir gereksinimse (plugin'ler, framework extension noktaları). O zaman exhaustiveness'ten vazgeçiyorsun ve _ kolu geri geliyor.

.NET blogunda Eylül 2026'da yayımlanan "Use C# unions and closed hierarchies in ASP.NET Core" yazısı da benzer bir ayrım yapıyor: kontrol ettiğin class'larla yeni bir API tasarlıyorsan discriminator'lı closed hierarchy, discriminator'sız mevcut bir contract'ı ya da primitive'leri modelliyorsan union.

Serialization: System.Text.Json Ne Yazıyor?

.NET 11'deki System.Text.Json iki yapıyı da tanıyor ama varsayılan davranışları çok farklı. Aynı üç durumu union, closed hierarchy ve closed hierarchy + InferClosedTypePolymorphism ile serialize edelim:

OrderStatus[] statuses = [new Pending(), new Paid("PAY-1001"), new Cancelled("customer request")];
string unionJson = JsonSerializer.Serialize(statuses, Web);
Console.WriteLine($"union:  {unionJson}");
try
{
    OrderStatus[] back = JsonSerializer.Deserialize<OrderStatus[]>(unionJson, Web)!;
    Console.WriteLine($"union back: {string.Join(", ", back.Select(s => s.Value?.GetType().Name ?? "null"))}");
}
catch (Exception ex)
{
    Console.WriteLine($"union back: {ex.GetType().Name}: {ex.Message}");
}

Closed.OrderStatus[] closed = [new Closed.Pending(), new Closed.Paid("PAY-1001"), new Closed.Cancelled("customer request")];
Console.WriteLine($"closed: {JsonSerializer.Serialize(closed, Web)}");

JsonSerializerOptions inferred = new(Web) { InferClosedTypePolymorphism = true };
string closedJson = JsonSerializer.Serialize(closed, inferred);
Console.WriteLine($"closed + infer: {closedJson}");
Closed.OrderStatus[] closedBack = JsonSerializer.Deserialize<Closed.OrderStatus[]>(closedJson, inferred)!;
Console.WriteLine($"closed back: {closedBack.SequenceEqual(closed)}");
$ dotnet run --project Bookstore.Fp.CSharp15 -- 08-json
union:  [{},{"paymentId":"PAY-1001"},{"reason":"customer request"}]
union back: JsonException: JSON value type 'Object' is ambiguous for union type 'Bookstore.Fp.CSharp15.OrderStatus' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $[0] | LineNumber: 0 | BytePositionInLine: 2.
closed: [{},{},{}]
closed + infer: [{"$type":"Pending"},{"$type":"Paid","paymentId":"PAY-1001"},{"$type":"Cancelled","reason":"customer request"}]
closed back: True

Union, aktif case'i kendi JSON şekliyle, hiçbir sarmalayıcı ya da discriminator olmadan yazıyor. Sonuç okunaklı, ama geri okurken üç case de JSON object olduğu için serializer hangisini seçeceğini bilemiyor. Closed hierarchy ise varsayılan olarak tanımlanan tip (OrderStatus) üzerinden serialize ediliyor ve onun hiç property'si olmadığı için üç boş object çıkıyor: ödeme ve iptal bilgisi exception bile olmadan kayboluyor. InferClosedTypePolymorphism (options'ta ya da [JsonPolymorphic] attribute'unda) bütün alt tipleri derleyicinin bildiği listeden çıkarıp $type discriminator'ı yazıyor ve round trip çalışıyor.

Union için çözüm bir type classifier. Ayrı bir deneme projesinde [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] attribute'unu union'a ekledik; bu classifier case'leri property adlarına bakarak ayırıyor. Paid ve Cancelled için çalıştı, ama hiç property'si olmayan Pending union'da olduğu sürece ilk kullanımda şu exception geldi:

Unhandled exception. System.NotSupportedException: The JsonUnionTypeStructuralClassifier cannot classify union type 'Status' because the case type 'Pending' can never be selected uniquely. The case type 'Paid' recognizes every property name recognized by 'Pending' and has no stricter required-property or unmapped-member constraints.

Pratik sonuç: dışarıya açılan bir contract'ta (API, queue mesajı) case bilgisinin JSON'da açıkça durmasını istiyorsan closed hierarchy ve discriminator daha güvenli. $type değerlerinin CLR (Common Language Runtime; .NET'in runtime'ı) tip adları olduğuna da dikkat: class'ın adını değiştirmek wire format'ı da değiştirir. TypeScript tarafıyla aynı kind değerlerini konuşmak istiyorsan beşinci yazıdaki gibi [JsonDerivedType(typeof(Pending), "pending")] ile adları açıkça vermek hâlâ en sağlam yol.

Veritabanı: EF Core ve Bu Tipler

Bu bölüm çalıştırılmadı, dokümana dayanıyor. Entity Framework Core'un (EF Core; .NET'in veritabanı erişim kütüphanesi) 11 sürümünün "What's new" sayfası (yazım gününde 3 Ağustos 2026 tarihli) union type'lardan ya da closed hierarchy'lerden söz etmiyor ve biz de EF Core ile denemedik. Closed bir record hiyerarşisi CLR açısından sıradan bir abstract base class ve alt tipleri; TPH (hiyerarşinin tek tabloda tutulması), TPT (tip başına tablo) ve TPC (somut tip başına tablo) inheritance mapping stratejilerinin onu nasıl karşılayacağı EF Core serisinin "EF Core'da Inheritance Mapping: TPH, TPT ve Hangisini Ne Zaman Seçmeli" yazısındaki kurallara tabi. Union ise Value'su object? olan bir struct ve bunu bir kolona ya da entity'ye eşlemek için belgelenmiş bir yol göremedik.

Beşinci yazıda önerdiğimiz ayrım burada daha da anlamlı: veritabanındaki satır bir durum kolonu ve case'e ait nullable kolonlarla dursun, sum type'ı repository ya da mapping katmanında kur. Domain modeli C# 15'in tipleriyle exhaustive olur, veritabanı şeması EF Core'un bildiği biçimde kalır. EF Core desteği geldiğinde (ya da geldiyse) ilk iş, onu kendi şemanla bir entegrasyon testinde denemek olmalı.

Library Sürümleme, LangVersion ve Breaking Change'ler

Union ve closed, derleyicinin yanında runtime'dan da tip istiyor. Bir library'yi hem .NET 10 hem .NET 11 için derliyorsan bunu hemen görürsün. net10.0 hedefleyen bir projede <LangVersion>15</LangVersion> ile bir union ve bir closed record tanımlayınca:

OrderStatus.cs(6,14): error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined or imported
OrderStatus.cs(6,14): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.UnionAttribute..ctor'
OrderStatus.cs(8,22): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.IsClosedTypeAttribute..ctor'

IUnion, UnionAttribute ve IsClosedTypeAttribute .NET 11 runtime'ında geliyor. net10.0'ın varsayılan dil sürümü zaten C# 14 ve orada union bir anahtar kelime bile değil. Multi-target bir library'de bu tipleri yalnızca net11.0 hedefine koyman ya da .NET 10 desteği bitene kadar beklemen gerekiyor.

Mevcut bir public API'yi union'a çevirmek ise daha sinsi. Beşinci yazıdaki gibi abstract record hiyerarşisi kullanan bir library düşün; bir uygulama ona karşı derlenmiş. Library'nin yeni sürümünde OrderStatus bir union oluyor, OrderText.Describe(OrderStatus) imzası kaynak kodda aynı kalıyor. Uygulamayı yeniden derlemeden yalnızca yeni DLL'i (derlenmiş assembly dosyası) kopyalayınca:

namespace Bookstore.Ordering;

public sealed record Pending;
public sealed record Paid(string PaymentId);
public union OrderStatus(Pending, Paid);

public static class OrderText
{
    public static string Describe(OrderStatus status) => status switch
    {
        Pending => "pending",
        Paid paid => $"paid with {paid.PaymentId}",
        null => "unknown"
    };
}
$ dotnet App/bin/Debug/net11.0/App.dll
paid with PAY-1001
$ cp Lib/bin/Debug/net11.0/Bookstore.Ordering.dll App/bin/Debug/net11.0/
$ dotnet App/bin/Debug/net11.0/App.dll
Unhandled exception. System.MissingMethodException: Method not found: 'System.String Bookstore.Ordering.OrderText.Describe(Bookstore.Ordering.OrderStatus)'.

Tipin adı aynı, ama class'tan struct'a dönüştü ve method imzası binary düzeyde değişti; yeniden derlenmemiş her çağıran runtime'da patlıyor. Uygulamayı yeniden derleyince (dotnet build) aynı kod çalışıyor, yani bu örnekte source düzeyinde uyumluluk korunuyor; ama status is OrderStatus.Paid gibi iç içe tip adları, OrderStatus'u null ile karşılaştıran kod ya da GetType()'a bakan kod yeniden derlemede de kırılır. Bir library için bu, major sürüm demek.

Bir de case eklemenin etkisi var. Exhaustive bir switch'in değeri, yeni bir case eklendiğinde uyarı vermesi. Ama bu uyarı library'yi kullananların kodunda çıkıyor: TreatWarningsAsErrors açık olan bir tüketici için public bir union'a ya da closed hiyerarşiye case eklemek bir build kırılması, yeniden derlenmemiş bir tüketici için ise SwitchExpressionException. Public API'de bir sum type'a case eklemeyi breaking change olarak ele al.

Sık Düşülen Tuzaklar

Closed hierarchy'yi discriminator'sız serialize etmek. Çıktıdaki [{},{},{}] satırı en tehlikeli sonuç: hata yok, veri yok. Closed bir base tip üzerinden serialize ettiğin her yerde InferClosedTypePolymorphism'i ya da [JsonDerivedType] attribute'larını bir testle doğrula.

Union'ın default değerini unutmak. Bir array'in element'leri, bir class'ın field'ı ya da default ile oluşturulan bir union'ın Value'su null. Derleyici, case tipleri nullable değilse bu durumda null kolu için her zaman uyarı vermiyor; switch o değeri alınca SwitchExpressionException fırlatıyor. Default değerin switch'e ulaşabileceği yerlerde null kolunu yaz; _ kolu yazma, çünkü o yeni case'lerle ilgili uyarıyı da susturur.

Value type case'lerde boxing'i gözden kaçırmak. Derleyicinin ürettiği union, int gibi value type case'leri object? içinde saklıyor; her değer bir heap allocation. Hot path'teki bir döngüde union Amount(int, decimal) gibi bir tip kullanıyorsan dokümandaki non-boxing access pattern'iyle (HasValue, TryGetValue) elle bir union yazmayı düşün.

closed'ın transitif olduğunu sanmak. Tahmin sorusundaki CancelledByWarehouse gibi, sealed olmayan bir case'in altına her assembly yeni tipler ekleyebiliyor. Case'leri sealed yaz; alt kırılım gerçekten gerekiyorsa ara tipi de closed yap.

Her abstract hiyerarşiyi union'a çevirmek. Union kalıtım ve ortak davranış getirmiyor; case'lerin ortak property'leri varsa her birini switch ile okumak zorunda kalırsın. Elinde zaten bir record hiyerarşisi varsa önce closed ekle; union'ı birbirinden bağımsız tipleri birleştirmen gereken yerlere sakla.

Küçük Bir Deneme

★☆☆ Closed.OrderStatus'a Delivered(string PaymentId, string TrackingNo, DateTimeOffset DeliveredAt) case'ini ekle. Hangi switch'ler uyarı veriyor? Aynı alıştırmayı beşinci yazıda C# 14 ile yaptıysan iki derleyici çıktısını karşılaştır.

★★☆ Closed.OrderStatus'a [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] ve her case için [JsonDerivedType] ekleyip beşinci yazıdaki TypeScript describe fonksiyonunun okuyabileceği JSON'u üret. InferClosedTypePolymorphism ile karşılaştırınca hangisi bir class'ın adını değiştirmeye karşı daha dayanıklı?

★★☆ OrderError union'ına QuantityRange(int Quantity) case'ini ekle ve StatusCode ile Message'daki uyarıları oku. Sonra StatusCode'a _ => 500 kolunu ekleyip yeni bir case daha ekle; uyarı ne oluyor?

★★★ Result<T> union'ını önceki yazıdaki class tabanlı Result<T> ile değiştirip OrderPlacement.PlaceAsync'i yeniden yaz. default(Result<T>)'nin akışa girebileceği bir yol var mı? Hangi tasarımı public bir library'de tercih ederdin?

Serinin son yazısında geri çekilip bütün bu araçların bedeline bakacağız: closure ve immutable kopyaların allocation'ı, LINQ (Language Integrated Query; C#'ın sorgu yazma özelliği) ile döngünün ve struct ile class Option'ın BenchmarkDotNet ile ölçülmüş farkı, derin composition'da debugging, ekipte hangi functional fikirlerin her zaman kazandırdığı ve hangilerinin tartışmalı olduğu. Bir de birinci yazıdaki LegacyCheckout'un serinin sonunda vardığı hâli yan yana koyacağız.