C# 15 Yenilikleri: Union Type, Closed Hierarchy ve Collection Argümanları
C# 10 ve C# 14'teki karşılıklarıyla C# 15: collection expression'da with(...) ile comparer ve capacity, union type'lar ve default değer tuzağı, closed hierarchy ile default kolsuz exhaustive switch, extension indexer, etiketli break/continue, eski runtime'larda static interface member'lar ve memory safety önizlemesi.
Sürüm notu: Bu yazı C# 15 ile gelen dil yeniliklerini anlatır; kodlar C# 15 / .NET 11 hedefiyle .NET 11 SDK (Software Development Kit, 11.0.100) ile derlenip denenmiştir.
Önce Tahmin Et
.NET 11 ve onunla gelen C# 15, 10 Kasım 2026'da GA (General Availability) oldu. İlk soru, günlük kodda en sık karşına çıkacak yenilikten. Aynı üç başlığı iki ayrı HashSet<string>'e koyuyoruz. İlk satırda hangi iki sayı çıkar? İkinci satır ne yazdırır?
HashSet<string> plain = ["Clean Code", "CLEAN CODE", "clean code"];
HashSet<string> ignoreCase = [with(StringComparer.OrdinalIgnoreCase), "Clean Code", "CLEAN CODE", "clean code"];
Console.WriteLine($"{plain.Count} {ignoreCase.Count}");
Console.WriteLine(ignoreCase.Contains("CLEAN code"));
Collection Expression Argümanları
Çıktı:
3 1
True
with(...) collection expression'ın ilk element'i olarak yazılıyor ve içindeki argümanları collection'ın constructor'ına iletiyor. Burada HashSet<string>'in comparer alan constructor'ı çağrıldı; büyük-küçük harf farkını yok sayan comparer üç başlığı tek element'e indirdi. İlk set'te comparer olmadığı için üçü de ayrı kaldı. C# 10'da aynı set'i şöyle kuruyordun:
var ignoreCase = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Clean Code", "CLEAN CODE", "clean code" };
C# 12 collection expression'ları getirdiğinde bu satır köşeli parantezli yazıma geçemedi, çünkü constructor'a argüman vermenin bir yolu yoktu. Comparer ya da capacity gerektiği anda eski yazıma dönüyordun; bir dosyada iki ayrı stil yan yana duruyordu. C# 15 bu boşluğu kapatıyor. Bookstore'dan birkaç örnek daha:
Book[] catalog =
[
new("978-0132350884", "Clean Code", 42.5m),
new("978-0134757599", "Refactoring", 38m),
new("978-0321125217", "Domain-Driven Design", 55m),
];
List<Book> cart = [with(capacity: catalog.Length * 2), .. catalog];
Console.WriteLine($"{cart.Count} / {cart.Capacity}");
Shelf shelf = [with("A1"), .. catalog];
Console.WriteLine($"{shelf.Code}: {shelf.Count} books");
IList<Book> wishlist = [with(capacity: 16)];
Console.WriteLine(wishlist.GetType().Name);
public record Book(string Isbn, string Title, decimal Price);
public class Shelf(string code) : List<Book>
{
public string Code { get; } = code;
}
3 / 6
A1: 3 books
List`1
cart için named argument ile List<T>'nin capacity constructor'ını çağırdık. Shelf'in parametresiz constructor'ı yok; C# 15'ten önce bu tipe collection expression ile değer verilemiyordu, şimdi raf kodunu with("A1") ile geçiyoruz. IList<Book> gibi bir interface hedefinde compiler arkada bir List<T> oluşturuyor ve capacity'yi ona veriyor. CollectionBuilder attribute'u ile builder yazdığın kendi tiplerinde ise argümanlar Create method'una gidiyor.
Önce with argümanları, sonra element'ler soldan sağa değerlendiriliyor. C# 14 ile derleyince: error CS9327: Feature 'collection expression arguments' is not available in C# 14.0. Please use language version 15.0 or greater. Bu yazıdaki diğer yeniliklerde de C# 14 aynı CS9327 hatasını veriyor, yalnızca tırnak içindeki özellik adı değişiyor; daha eski sürümler de reddediyor.
Union Type'lar
Bir ödeme ya başarılı olur ya reddedilir. C# 10'da bunu modellemenin yaygın yolu ortak bir abstract record'du:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Describe(Charge(80.5m)));
Console.WriteLine(Describe(Charge(250m)));
static PaymentResult Charge(decimal total) =>
total <= 100m ? new Paid("tx-1001", total) : new Declined("limit exceeded");
static string Describe(PaymentResult result) => result switch
{
Paid p => $"paid {p.Amount} ({p.TransactionId})",
Declined d => $"declined: {d.Reason}",
};
public abstract record PaymentResult;
public record Paid(string TransactionId, decimal Amount) : PaymentResult;
public record Declined(string Reason) : PaymentResult;
Kod çalışıyor, ama compiler bir uyarı veriyor:
Program.cs(11,56): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.
Compiler'ın açısından haklı: PaymentResult açık bir base class, yarın başka bir assembly ondan yeni bir tip türetebilir. _ => throw ... kolu uyarıyı susturur ama o andan sonra compiler sana hiçbir şey söyleyemez. Üstelik iki tipi bir arada tutmak için onları aynı inheritance ağacına sokmak zorundasın. C# 15'teki union type bu iki sorunu birlikte çözüyor:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(Describe(Charge(80.5m)));
Console.WriteLine(Describe(Charge(250m)));
PaymentResult result = Charge(80.5m);
Console.WriteLine(result.Value);
Console.WriteLine(result.GetType().IsValueType);
static PaymentResult Charge(decimal total) =>
total <= 100m ? new Paid("tx-1001", total) : new Declined("limit exceeded");
static string Describe(PaymentResult result) => result switch
{
Paid p => $"paid {p.Amount} ({p.TransactionId})",
Declined d => $"declined: {d.Reason}",
};
public record Paid(string TransactionId, decimal Amount);
public record Declined(string Reason);
public union PaymentResult(Paid, Declined);
paid 80.5 (tx-1001)
declined: limit exceeded
Paid { TransactionId = tx-1001, Amount = 80.5 }
True
Bu kez uyarı yok. Paid ve Declined birbirinden bağımsız iki record; ortak bir base class'ları yok. union PaymentResult(Paid, Declined) satırı bu iki tipi bir "case type" listesi olarak tanımlıyor. Her case type'tan union'a implicit dönüşüm var; Charge bu yüzden doğrudan new Paid(...) dönebiliyor. Switch'teki Paid p pattern'i union'ın kendisini değil içindeki değeri test ediyor. Liste kapalı olduğu için compiler iki kolun yettiğini biliyor.
Arkada ne olduğunu son iki satır gösteriyor. Compiler union'ı [Union] attribute'u taşıyan ve IUnion interface'ini implemente eden bir struct'a çeviriyor; içerik object? tipinde bir Value property'sinde duruyor. Asıl kazancı listeye yeni bir case eklediğinde görürsün:
public record Pending(string Step);
public union PaymentResult(Paid, Declined, Pending);
Program.cs(8,56): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Pending' is not covered.
Uyarı artık eksik case'in adını veriyor. Projendeki her switch, yeni durumu ele alana kadar sarı yanar. Uyarıyı görmezden gelip Pending ile çağırırsan runtime'da SwitchExpressionException alırsın.
Case type'ların class olması da gerekmiyor. Bir kitabı ISBN'iyle ya da eski sistemden kalan sayısal id'siyle arayabildiğimizi düşün:
BookKey byIsbn = "978-0132350884";
BookKey byLegacyId = 1042;
Console.WriteLine(Find(byIsbn));
Console.WriteLine(Find(byLegacyId));
Console.WriteLine(byLegacyId.Value?.GetType().Name);
Console.WriteLine(byIsbn is string);
static string Find(BookKey key) => key switch
{
string isbn => $"lookup by ISBN {isbn}",
int id => $"lookup by legacy id {id}",
};
public union BookKey(string, int);
lookup by ISBN 978-0132350884
lookup by legacy id 1042
Int32
True
Küçük bir bedel var: int değeri object? alanında saklandığı için boxing oluyor. Sıcak bir döngüde bu önemliyse union'ı [Union] attribute'u ile kendin yazıp HasValue ve TryGetValue member'larıyla boxing'i kaldırabilirsin.
Union'ın İçinde Ne Var: null ve default
Union bir struct olduğu için her struct gibi bir default değeri var ve o değerin içi boş. Şu koda bak:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var results = new PaymentResult[2];
results[0] = new Paid("tx-1001", 80.5m);
foreach (PaymentResult r in results)
Console.WriteLine(Describe(r));
static string Describe(PaymentResult result) => result switch
{
Paid p => $"paid {p.Amount}",
Declined d => $"declined: {d.Reason}",
};
public record Paid(string TransactionId, decimal Amount);
public record Declined(string Reason);
public union PaymentResult(Paid, Declined);
Derleme temiz, hiçbir uyarı yok. Çalıştırınca:
paid 80.5
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was PaymentResult.
Array'in ikinci element'i default(PaymentResult); Value'su null. Case type'ların hiçbiri nullable olmadığı için compiler Value'yu "null değil" kabul ediyor ve eksik null kolu için uyarmıyor. Default değerin sızabileceği her yerde (array, initialize edilmemiş field, default) switch'e açıkça bir null kolu ekle:
static string Describe(PaymentResult result) => result switch
{
Paid p => $"paid {p.Amount}",
Declined d => $"declined: {d.Reason}",
null => "no result",
};
paid 80.5
no result
_ yerine null kolunu seçmenin sebebi, yarın eklenecek yeni case için uyarıyı korumak; _ her şeyi yutar. Bir de union'ın kendisini pattern ile test etmeye kalkma: byIsbn is BookKey satırı CS8121 hatası verdi, çünkü type pattern'leri her zaman içeriğe bakıyor. İçerik var mı diye sormak için is not null yaz.
Closed Hierarchy'ler
Union, birbirinden bağımsız tipleri bir araya getiriyor. Tipler senin elindeyse ve ortak bir base class paylaşmaları anlamlıysa C# 15'in ikinci yeniliği daha doğal: closed modifier'ı.
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
OrderStatus[] statuses =
[
new Placed(),
new Shipped(new DateOnly(2026, 11, 12)),
new Cancelled("out of stock"),
];
foreach (OrderStatus status in statuses)
Console.WriteLine(Describe(status));
static string Describe(OrderStatus status) => status switch
{
Placed => "placed",
Shipped(var date) => $"shipped on {date:yyyy-MM-dd}",
Cancelled(var reason) => $"cancelled: {reason}",
};
public closed record OrderStatus;
public record Placed : OrderStatus;
public record Shipped(DateOnly Date) : OrderStatus;
public record Cancelled(string Reason) : OrderStatus;
placed
shipped on 2026-11-12
cancelled: out of stock
Aynı kodu closed yerine abstract ile yazsaydın ödeme örneğindeki CS8509 uyarısını yine alırdın; closed ile uyarı yok. Closed bir class'tan doğrudan türetme yalnızca aynı assembly içinde yapılabiliyor; compiler doğrudan alt tiplerin listesini derleme anında biliyor ve üç kolu yeterli sayıyor. Cancelled kolunu silince CS8509 geri geliyor, bu kez eksik pattern olarak 'Cancelled' adını vererek.
Kuralı görmek için OrderStatus'u ayrı bir library projesine (Bookstore.Orders) taşıdım ve uygulama projesinden türetmeyi denedim:
using Bookstore.Orders;
OrderStatus status = new Returned("damaged");
Console.WriteLine(status);
public record Returned(string Reason) : OrderStatus;
Program.cs(6,15): error CS9382: 'Returned': cannot use a closed type 'OrderStatus' from another assembly as a base type.
Birkaç kural daha var. Closed bir class örtük olarak abstract: new OrderStatus() CS0144 hatası veriyor. closed ile sealed birlikte kullanılamıyor (CS9381). En önemlisi kapalılık miras kalmıyor: aynı uygulama projesinde public record PartiallyCancelled(string Reason, int Items) : Cancelled(Reason); sorunsuz derlendi ve PartiallyCancelled { Reason = customer request, Items = 2 } yazdırdı. Cancelled'ın da dışarıdan genişletilmesini istemiyorsan onu sealed ya da closed yap.
Hangisini seçeceğin sorusunun kısa cevabı şu. Tipler zaten var, birbirinden bağımsız ya da string ve int gibi senin olmayan tiplerse union. Tipler senin domain'inin parçası, ortak data veya davranış paylaşıyorlarsa closed hierarchy. Closed hierarchy sıradan class'lardan oluştuğu için boxing derdi de yok.
Extension Indexer'lar
C# 14 extension bloklarıyla property, static member ve operatör eklemeyi getirmişti; eksik kalan indexer'dı. C# 10'da ISBN ile kitap bulmak için bir extension method yazıyordun:
public static class CatalogExtensions
{
public static Book? FindByIsbn(this IEnumerable<Book> books, string isbn) =>
books.FirstOrDefault(b => b.Isbn == isbn);
}
C# 15'te aynı şey bir indexer olabiliyor:
IEnumerable<Book> catalog =
[
new("978-0132350884", "Clean Code", 42.5m),
new("978-0134757599", "Refactoring", 38m),
];
Console.WriteLine(catalog["978-0134757599"]?.Title);
Console.WriteLine(catalog["000"]?.Title ?? "not found");
List<Book> list = [.. catalog];
Console.WriteLine(list["978-0132350884"]?.Title);
Console.WriteLine(list[0].Title);
public record Book(string Isbn, string Title, decimal Price);
public static class CatalogExtensions
{
extension(IEnumerable<Book> books)
{
public Book? this[string isbn] => books.FirstOrDefault(b => b.Isbn == isbn);
}
}
Refactoring
not found
Clean Code
Clean Code
İlginç olan son iki satır. List<Book>'un kendi int indexer'ı var; string argümanla bu indexer uygulanamayınca compiler extension indexer'a geçti, list[0] ise yine kendi indexer'ına gitti. Indexer her zaman instance member olduğu için extension bloğunda receiver'a isim vermek (books) zorunlu.
Dikkat: indexer'ın arkasında doğrusal bir arama var. catalog["..."] yazımı bir dictionary erişimi gibi görünüyor ama her çağrıda listeyi baştan tarıyor. Okuyanı yanıltacak bir indexer yazma; çok arama yapacaksan önce bir Dictionary<string, Book> kur.
Etiketli break ve continue
İç içe döngüden çıkmak, C# Temelleri serisinden beri bildiğimiz bir dert. Stokta olmayan bir kitap içeren ilk siparişi bulmak için C# 10'da bir bayrak değişkeni tutuyordun:
var stock = new Dictionary<string, int> { ["Clean Code"] = 3, ["Refactoring"] = 0, ["Domain-Driven Design"] = 5 };
var orders = new List<Order>
{
new(1001, new[] { "Clean Code", "Domain-Driven Design" }),
new(1002, new[] { "Refactoring", "Clean Code" }),
new(1003, new[] { "Domain-Driven Design" }),
};
Order? blocked = null;
foreach (Order order in orders)
{
bool found = false;
foreach (string title in order.Titles)
{
if (stock[title] == 0)
{
found = true;
break;
}
}
if (found)
{
blocked = order;
break;
}
}
Console.WriteLine($"first blocked order: {blocked?.Id}");
public record Order(int Id, string[] Titles);
Diğer seçenek goto idi. C# 15'te break ve continue çevreleyen bir döngünün etiketini alabiliyor:
Dictionary<string, int> stock = new() { ["Clean Code"] = 3, ["Refactoring"] = 0, ["Domain-Driven Design"] = 5 };
List<Order> orders =
[
new(1001, ["Clean Code", "Domain-Driven Design"]),
new(1002, ["Refactoring", "Clean Code"]),
new(1003, ["Domain-Driven Design"]),
];
Order? blocked = null;
orders:
foreach (Order order in orders)
{
foreach (string title in order.Titles)
{
if (stock[title] == 0)
{
blocked = order;
break orders;
}
}
}
Console.WriteLine($"first blocked order: {blocked?.Id}");
List<int> shippable = [];
nextOrder:
foreach (Order order in orders)
{
foreach (string title in order.Titles)
{
if (stock[title] == 0)
continue nextOrder;
}
shippable.Add(order.Id);
}
Console.WriteLine($"shippable: {string.Join(", ", shippable)}");
public record Order(int Id, string[] Titles);
first blocked order: 1002
shippable: 1001, 1003
İki sürüm de 1002 diyor, ama yenisinde bayrak yok. break orders dış döngüden tamamen çıkıyor, continue nextOrder dış döngünün sonraki turuna geçiyor. Etiket doğrudan döngünün (ya da switch statement'ın) önüne yazılıyor; etiketsiz break ve continue eskisi gibi en içteki döngüye bakıyor. Etiketi döngüye değil başka bir satıra koyarsan compiler CS9393 hatasıyla durduruyor.
Eski Runtime'lar ve Memory Safety Önizlemesi
Listede iki madde daha var. İlki library yazanları ilgilendiriyor. C# 8'den beri interface'lere static member yazılabiliyor, ama bu özellik default interface implementation desteği olan bir runtime istiyordu; .NET Framework'ü hedefleyen bir projede kullanılamıyordu. C# 15 bu kısıtı non-virtual static member'lar için kaldırıyor:
using System;
Console.WriteLine(IPriced.WithVat(100m, 0.20m));
public interface IPriced
{
decimal Price { get; }
public static decimal WithVat(decimal price, decimal rate) => price * (1 + rate);
}
Bu kodu TargetFramework net48 iken C# 15 ile derledim, sorunsuz geçti. LangVersion 14 ile aynı proje CS9327 verdi. Hem .NET Framework'ü hem modern .NET'i hedefleyen bir library'de yardımcı method'u interface'in yanına koyabilirsin. static abstract/static virtual member'lar ve instance default implementation'lar ise hâlâ runtime desteği istiyor.
İkincisi bir önizleme: .NET 11 SDK, unsafe gerekliliğini pointer tipinin varlığına değil belleğe gerçekten erişen işlemlere bağlayan, birkaç sürüme yayılacak bir memory safety çalışmasının ilk adımını getiriyor. Normal C# 15'te şu kod üç kez CS0214 (pointer yalnızca unsafe context'te kullanılabilir) hatası veriyor:
int stock = 42;
int* pointer = &stock;
Console.WriteLine(pointer != null);
LangVersion preview ve AllowUnsafeBlocks açıkken aynı kod derlenip True yazdırdı. Pointer'ın gösterdiği belleği okumak (*pointer) ise yine unsafe istiyor. Preview dil sürümü production kodu için uygun değil; yalnızca yönü görmek için dene.
Sık Düşülen Tuzaklar
Union'ın default değerini unutmak. Yukarıda gördüğün gibi compiler uyarmıyor, runtime'da SwitchExpressionException alıyorsun. Array'lerde, field'larda ve default ile oluşan değerlerde null kolu yaz.
Eski LangVersion'da anlamlı bir hata beklemek. Diğer özelliklerin aksine union satırı C# 14'te "feature not available" vermiyor; compiler satırı tanımadığı için şu hataları diziyor:
Program.cs(24,1): error CS0106: The modifier 'public' is not valid for this item
Program.cs(24,1): error CS8803: Top-level statements must precede namespace and type declarations.
Program.cs(24,32): error CS1001: Identifier expected
Program.cs(24,42): error CS1001: Identifier expected
union geçen bir dosyada bunları görürsen önce dil sürümüne bak.
Adı with olan bir method'u collection expression içinde çağırmak. string[] titles = [with("Clean Code"), "Refactoring"]; satırı, aynı dosyada with adında bir local function olmasına rağmen error CS9355: 'with(...)' elements are not supported for type 'string[]' verdi; .NET 11 SDK ile LangVersion 14'te de. Method'u kastediyorsan @with(...) yaz.
Yeni contextual keyword'leri hafife almak. Önceki yazıda CS8981 uyarısı "tamamen küçük harfli tip isimleri ileride rezerve olabilir" diyordu. C# 15'te bu oldu: public class closed { } artık error CS9380: Types and aliases cannot be named 'closed'. veriyor; union için de benzer bir kural var. Böyle bir tipin varsa adını değiştir ya da @closed yaz.
Küçük Bir Deneme
★☆☆ PaymentResult union'ına Refunded(decimal Amount) case'ini ekle. Projede kaç tane CS8509 uyarısı çıkıyor? Hepsini _ kullanmadan düzelt.
★★☆ Shelf'e raf kodu ve capacity alan ikinci bir constructor ekle ve [with("A1", 32), .. catalog] ile kullan. Overload seçimi with argümanlarına göre doğru çalışıyor mu?
★★★ BookKey'i [Union] attribute'u ile kendin yaz; HasValue ve iki TryGetValue ekleyerek int case'i için boxing'i kaldır. Switch kodu değişiyor mu?
Böylece C# 15'e ulaştık. .NET 11 bir STS (Standard-Term Support, standart süreli destek) sürümü; Microsoft'un destek tablosuna göre desteği 14 Kasım 2028'de, LTS (Long-Term Support, uzun süreli destek) sürümü .NET 10 ile aynı gün bitiyor. Mevcut bir projeyi taşıyacaksan önceki yazıdaki adımlar .NET 11 için de geçerli. Seri, bir sonraki dil sürümüyle aynı yöntemle devam edecek.