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

Option ve Null'dan Kaçış: Maybe Tipi, Map, Bind ve LINQ Query Syntax'ın Sırrı

Mert Özen 15 Kas 2026 15 dk 9 görüntülenme
Option ve Null'dan Kaçış: Maybe Tipi, Map, Bind ve LINQ Query Syntax'ın Sırrı

Option, "değer yok" durumunu bir tip olarak ifade eder ve değere ulaşmadan önce "yoksa ne olacak?" sorusunu cevaplamaya zorlar; nullable reference types çoğu yerde yeterliyken Option zincirleme hesaplamada ve iç içe "yok" anlamlarında fark yaratır. Bookstore'un kupon hesabını kendi Option<T>'imizle Map, Bind ve Match üzerinden kuruyor, Select/SelectMany ekleyince LINQ query syntax'ın neden çalıştığını ve monad'ın map + flatten sezgisini gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, TypeScript örnekleri TypeScript 7.0.2 ve Node.js 26 ile derlenip denenmiştir.

Önce Tahmin Et: İki Farklı "Yok"

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kampanya oranları kupon koduna göre bir dictionary'de duruyor. Pazarlama ekibi VIP kuponunu sisteme eklemiş ama oranına henüz karar vermemiş; bu yüzden değeri null. SPRING5 diye bir kupon ise hiç yok. Bir de satır bazında indirimleri tutan boş bir array var:

// VIP: oran henüz belli değil
Dictionary<string, decimal?> rates = new() { ["AUTUMN10"] = 0.10m, ["VIP"] = null };

decimal? vip = rates.GetValueOrDefault("VIP");
decimal? spring = rates.GetValueOrDefault("SPRING5");
Console.WriteLine($"VIP: {vip?.ToString() ?? "null"}, SPRING5: {spring?.ToString() ?? "null"}, same: {vip == spring}");

decimal[] lineDiscounts = [];
Console.WriteLine($"first discount: {lineDiscounts.FirstOrDefault()}");

Ekranda ne çıkar? Daha önemlisi: çıktıya bakan kod, "kupon var ama oranı yok" ile "böyle bir kupon yok" durumlarını birbirinden ayırabilir mi?

Cevap: null ve default Birden Fazla Şey Söylüyor

$ dotnet run --project src/Bookstore.Fp.Demo -- 06-guess
VIP: null, SPRING5: null, same: True
first discount: 0

İki farklı durum aynı değere düşüyor. VIP kuponu katalogda var ama oranı belirsiz; SPRING5 hiç yok. GetValueOrDefault ikisi için de null döndürüyor ve aradaki fark kayboluyor. Boş array'in FirstOrDefault()'u ise 0 döndürüyor: "hiç indirim satırı yok" ile "ilk satırdaki indirim sıfır" aynı görünüyor. Kasada bu, kampanyası tanımlanmamış bir kuponu kullanan müşteriye "geçersiz kupon" yerine "indirim 0 TL" demek anlamına geliyor.

Sorun null'ın kendisi değil, tek bir değerin birden fazla anlam taşıması. null bazen "henüz atanmadı", bazen "bulunamadı", bazen "uygulanamaz", bazen de bir bug'ın işareti. Null referansı 1965'te Pascal'ın öncülü sayılan bir programlama dili (ALGOL W) için ortaya koyan Tony Hoare'ın, 2009'da QCon London'daki konuşmasında bunu kendi milyar dolarlık hatası olarak nitelendirmesi bu yüzden sık anılır. Functional dillerin cevabı "değer yok" durumunu bir tip olarak ifade etmek: F#'ta option, Haskell'de Maybe, Rust'ta Option. Bu yazıda C#'ta kendi Option<T>'imizi yazıp onu zincirleme hesaplamalarda kullanacağız; ama önce C#'ın zaten verdiği korumaya bakalım.

NRT Nerede Yeter, Nerede Yetmez?

C# 8'den beri NRT (nullable reference types; derleyicinin null olabilecek referansları derleme zamanında takip etmesi) var ve çoğu durumda yeterli. Campaign? ile Campaign farklı şeyler söylüyor; null olabilecek bir referansa kontrolsüz erişince derleyici uyarıyor:

Dictionary<string, Campaign> campaigns = new();
Campaign? campaign = campaigns.GetValueOrDefault("AUTUMN10");
decimal rate = campaign.Rate;

decimal? memberDiscount = null;
decimal value = memberDiscount.Value;
Program.cs(5,16): warning CS8602: Dereference of a possibly null reference.
Program.cs(8,17): warning CS8629: Nullable value type may be null.

Attribute'larla (NotNullWhen, MaybeNull) API'yi nasıl daha dürüst hâle getireceğin C# İleri serisinin "C#'ta Nullable Reference Types Derinlemesine: NotNullWhen, MaybeNull ve API Tasarımı" yazısında. Bir DTO'nun (data transfer object; API sınırında veri taşıyan sade tip) property'si, bir method parametresi, bir entity'nin opsiyonel kolonu için NRT doğru araç: ek tip yok, System.Text.Json ve Entity Framework Core (EF Core; .NET'in veritabanı erişim kütüphanesi) onu doğrudan tanıyor.

NRT'nin yetmediği üç yer var. Birincisi, uyarılar yalnızca uyarı: derleme yine geçiyor, runtime'da null hâlâ mümkün. İkincisi, "yok"un iç içe geçtiği durumları ifade edemiyor; decimal?? diye bir tip yok, tahmin sorusundaki iki farklı "yok" bu yüzden aynı değere düştü. Üçüncüsü, zincirleme hesaplamada her adımı ayrı ayrı kontrol etmen gerekiyor: kodu temizle, boşsa dur, katalogda ara, yoksa dur, kampanya aktif mi bak, değilse dur, indirimi hesapla. ?. operatörü yalnızca member erişimini zincirliyor; araya bir dictionary araması ya da bir koşul girince yine if'lere dönüyorsun. Option'ın değer kattığı yer bu üçüncüsü ve ikincisi.

Kendi Option<T>'ini Yazmak

Option ya bir değer taşır (Some) ya da hiçbir şey taşımaz (None). Önceki yazıdaki sum type'ın en küçük örneği: iki case, birinde veri var, diğerinde yok. Struct olarak yazınca default(Option<T>) kendiliğinden None oluyor:

namespace Bookstore.Ordering.Functional;

// default(Option<T>) None'dır: içinde değer olmayan bir struct
public readonly record struct Option<T>
{
    private readonly T _value;

    private Option(T value) => (_value, IsSome) = (value, true);

    public bool IsSome { get; }

    public static Option<T> None => default;

    public static Option<T> Some(T value) =>
        value is null ? throw new ArgumentNullException(nameof(value)) : new Option<T>(value);

    public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) => IsSome ? some(_value) : none();

    public Option<TOut> Map<TOut>(Func<T, TOut> map) => IsSome ? Option<TOut>.Some(map(_value)) : Option<TOut>.None;

    public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> bind) => IsSome ? bind(_value) : Option<TOut>.None;

    public Option<T> Where(Func<T, bool> predicate) => IsSome && predicate(_value) ? this : None;

    public T GetValueOrDefault(T fallback) => IsSome ? _value : fallback;

    public override string ToString() => IsSome ? $"Some({_value})" : "None";
}

Bilinçli verilmiş birkaç karar var. Değer private; dışarıdan ona ulaşmanın tek yolu Match, Map, Bind ya da bir varsayılan değer vererek GetValueOrDefault. Yani "değer yoksa ne olacak?" sorusunu cevaplamadan içeriye ulaşamıyorsun. Some(null) exception fırlatıyor; aksi hâlde Some içinde null taşıyan bir Option, çözmeye çalıştığımız belirsizliği geri getirirdi. record struct olduğu için eşitlik derleyiciden hazır geliyor; iki None birbirine eşit. Struct mı class mı sorusunun performans tarafına serinin son yazısında, ölçerek döneceğiz.

Option<decimal> some = Option.Some(0.10m);
Option<decimal> none = Option<decimal>.None;
Option<decimal> fromDefault = default;

Console.WriteLine($"{some}, {none}, default: {fromDefault}, equal: {none == fromDefault}");
Console.WriteLine(some.Match(rate => $"rate {rate}", () => "no rate"));
Console.WriteLine(none.Match(rate => $"rate {rate}", () => "no rate"));
Console.WriteLine($"map: {some.Map(rate => rate * 100)}, {none.Map(rate => rate * 100)}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-basics
Some(0.10), None, default: None, equal: True
rate 0.10
no rate
map: Some(10.00), None

Map, içerideki değere bir fonksiyon uygulayıp sonucu yine Option olarak döndürüyor; None ise fonksiyon hiç çağrılmadan None kalıyor. Mevcut koda bağlanmak için birkaç küçük yardımcı da ekliyoruz: nullable bir değeri Option'a çeviren ToOption, dictionary'de "bulunamadı"yı None olarak döndüren Find ve ileride göreceğimiz LINQ (Language Integrated Query; C#'ın sorgu yazma özelliği) method'ları:

public static class Option
{
    public static Option<T> Some<T>(T value) => Option<T>.Some(value);

    public static Option<T> ToOption<T>(this T? value) where T : class =>
        value is null ? Option<T>.None : Option<T>.Some(value);

    public static Option<T> ToOption<T>(this T? value) where T : struct =>
        value.HasValue ? Option<T>.Some(value.Value) : Option<T>.None;

    public static Option<TValue> Find<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> source, TKey key) =>
        source.TryGetValue(key, out TValue? value) ? Option<TValue>.Some(value) : Option<TValue>.None;
}

İki ToOption overload'ı yalnızca constraint'leriyle değil parametre tipleriyle de ayrışıyor (T ve Nullable<T>), bu yüzden C# ikisine birlikte izin veriyor.

Map ve Bind: Kupon Kodundan İndirime

Şimdi kasadaki kupon hesabını Option ile yazalım. Adımlar: müşterinin yazdığı kodu temizle (boşlukları at, büyük harfe çevir, boşsa vazgeç), kampanyayı katalogda bul, kampanya bugün aktif mi bak, aktifse indirimi hesapla, hiçbir şey çıkmadıysa indirim sıfır:

public static class CouponLookup
{
    public static Option<string> NormalizeCode(string? code) =>
        code.ToOption()
            .Map(text => text.Trim().ToUpperInvariant())
            .Where(text => text.Length > 0);

    public static Option<Campaign> ActiveCampaign(
        string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
        NormalizeCode(code)
            .Bind(normalized => campaigns.Find(normalized))
            .Where(campaign => campaign.IsActiveAt(now));

    public static decimal CouponDiscount(
        decimal subtotal, string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
        ActiveCampaign(code, campaigns, now)
            .Map(campaign => Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero))
            .GetValueOrDefault(0m);
}

Her satır tek bir iş yapıyor ve hiçbirinde null kontrolü yok. Map ile Bind arasındaki fark burada görünüyor. Trim().ToUpperInvariant() her zaman bir string döndürüyor, o yüzden Map. campaigns.Find(...) ise kendisi bir Option döndürüyor; onu Map ile çağırsaydık elimize Option<Option<Campaign>> geçerdi. Bind, fonksiyonu çalıştırıp çıkan Option'ı doğrudan döndürüyor, yani iç içe geçmeyi düzleştiriyor. Kural kısa: fonksiyon düz değer döndürüyorsa Map, Option döndürüyorsa Bind.

$ dotnet run --project src/Bookstore.Fp.Demo -- 06-chain
"AUTUMN10"     campaign: Some(AUTUMN10)   discount: 24.94
" autumn10 "   campaign: Some(AUTUMN10)   discount: 24.94
"SUMMER15"     campaign: None             discount: 0
"SPRING5"      campaign: None             discount: 0
""             campaign: None             discount: 0
null           campaign: None             discount: 0

Bu çıktı, üçüncü yazıdaki alıştırmanın sorusunu da cevaplıyor: küçük harfle yazılan kupon artık bir iş kuralıyla (NormalizeCode) kabul ediliyor. Kasım ortasında SUMMER15 katalogda var ama aktif değil; SPRING5 katalogda yok; boş string ve null kod hiç aranmıyor. Dört farklı "yok" sebebi aynı sonuca, sıfır indirime gidiyor. Bu kez bu bilinçli bir karar: zincirin sonunda GetValueOrDefault(0m) diyerek "yoksa sıfır" kararını tek bir yerde veriyoruz. Ekran "kupon geçersiz" ile "kupon süresi dolmuş" arasında ayrım yapmak isteseydi Option artık yetmezdi; neden yok olduğunu taşıyan bir tipe, Result'a ihtiyaç olurdu.

Tahmin sorusundaki iki farklı "yok" için Option'ın iç içe kullanımı da işe yarıyor. Dune katalogda var ama fiyatı henüz girilmedi; listede Option<decimal> değerleri tutalım:

// Dune katalogda var ama fiyatı henüz girilmedi
Dictionary<string, Option<decimal>> listPrices = new()
{
    ["9780141439587"] = Option.Some(59.90m),
    ["9780441013593"] = Option<decimal>.None
};

string[] isbns = ["9780141439587", "9780441013593", "9780000000000"];
foreach (string isbn in isbns)
{
    Option<Option<decimal>> nested = listPrices.Find(isbn);
    Option<decimal> flat = listPrices.Find(isbn).Bind(price => price);
    string meaning = nested.Match(
        price => price.Match(value => $"price {value}", () => "in catalog, no price yet"),
        () => "not in catalog");
    Console.WriteLine($"{isbn}: {nested,-15} {flat,-12} {meaning}");
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-nested
9780141439587: Some(Some(59.90)) Some(59.90)  price 59.90
9780441013593: Some(None)      None         in catalog, no price yet
9780000000000: None            None         not in catalog

Some(None) ile None farklı değerler; decimal? ile ifade edemediğimiz ayrım burada duruyor. Ayrım gerekmiyorsa Bind(price => price) iki katmanı tek katmana indiriyor. Bu "düzleştirme" işleminin adı flatten ve bir sonraki bölümün konusu.

LINQ Query Syntax'ın Sırrı: Select ve SelectMany

LINQ'in query syntax'ı yalnızca IEnumerable<T> için değil. C# dil spesifikasyonunun query expression bölümüne göre derleyici from ... select ifadesini Select, SelectMany, Where gibi adlarla yapılan method çağrılarına çeviriyor ve bu method'ların hangi tipte tanımlandığına bakmıyor. Doğru imzalı method'lar bulunduğu sürece query syntax her tipte çalışıyor. Option için iki extension yeterli:

// LINQ query syntax bu adlara ve bu imzalara sahip method'ları arar
public static Option<TOut> Select<T, TOut>(this Option<T> option, Func<T, TOut> map) => option.Map(map);

public static Option<TOut> SelectMany<T, TMid, TOut>(
    this Option<T> option, Func<T, Option<TMid>> bind, Func<T, TMid, TOut> project) =>
    option.Bind(first => bind(first).Map(second => project(first, second)));

Where zaten Option'ın kendi method'u. Artık aynı kupon hesabı şöyle yazılabiliyor:

// Aynı zincir, LINQ query syntax ile
public static Option<decimal> CouponDiscountQuery(
    decimal subtotal, string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
    from normalized in NormalizeCode(code)
    from campaign in campaigns.Find(normalized)
    where campaign.IsActiveAt(now)
    select Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero);
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-query
" autumn10 "   query: Some(24.94)  method: 24.94
"SUMMER15"     query: None         method: 0
null           query: None         method: 0

Derleyici ikinci from'u SelectMany'ye, where'i Where'e, select'i Select'e çeviriyor; elle yazınca kabaca şuna denk geliyor:

Option<decimal> translated = CouponLookup.NormalizeCode(code)
    .SelectMany(normalized => campaigns.Find(normalized), (normalized, campaign) => new { normalized, campaign })
    .Where(pair => pair.campaign.IsActiveAt(now))
    .Select(pair => Math.Round(subtotal * pair.campaign.Rate, 2, MidpointRounding.AwayFromZero));

Query syntax'ın avantajı, her adımda önceki adımların değişkenlerine (normalized, campaign) erişebilmen; method zincirinde bunun için lambda'ları iç içe yazman gerekir. Bir testle iki yazımın aynı sonucu verdiğini de doğruluyoruz (Query_syntax_matches_the_method_chain). SelectMany'nin koleksiyonlardaki davranışı LINQ serisinin "LINQ ile Projection: Select, SelectMany ve Anonymous Type" yazısında.

Aynı Kalıp Her Yerde: Map ve Flatten

SelectMany'nin koleksiyonlarda yaptığı iş de aynı: her siparişten bir ISBN (kitapların uluslararası standart numarası) listesi çıkıyor, Select kullansaydık liste listesi elde ederdik, SelectMany onları tek listeye indiriyor. Option'da da Map iç içe Option üretiyor, Bind düzleştiriyor:

IEnumerable<string> isbns =
    from order in new[] { new[] { "9780441013593", "9780141439587" }, new[] { "9780261103344" } }
    from isbn in order
    select isbn;
Console.WriteLine($"IEnumerable: {string.Join(", ", isbns)}");

Option<decimal> rate =
    from code in CouponLookup.NormalizeCode("autumn10")
    from campaign in Campaigns.Find(code)
    select campaign.Rate;
Console.WriteLine($"Option: {rate}");

Console.WriteLine($"Task: {await AfterMemberDiscountAsync()}");

// await, Task'ı Bind'ın Option'ı açtığı gibi açar
private static async Task<decimal> AfterMemberDiscountAsync() => await LoadSubtotalAsync() - 15m;
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-shapes
IEnumerable: 9780441013593, 9780141439587, 9780261103344
Option: Some(0.10)
Task: 234.40

Bu ortak şeklin adı monad: bir değeri saran bir tip (Option, liste, Task), sarılı değere fonksiyon uygulayan bir Map ve sarmalayan tipi döndüren bir fonksiyonu iç içe geçirmeden zincirleyen bir Bind. Haskell'de bu işlem >>=, F#'ta computation expression'larda let!, C#'ta query syntax'ta ikinci from. Matematiksel tanımın bazı kuralları da var (örneğin Some(x).Bind(f)'nin f(x) ile aynı sonucu vermesi), ama günlük kod için sezgi yeterli: "sarılı değerle çalış, sarmalayıcıyı iç içe geçirme".

TipMap karşılığıBind (map + flatten) karşılığı
Option<T>MapBind
IEnumerable<T>SelectSelectMany
Task<T>await sonrası düz değer döndürmekawait sonrası Task döndüren bir çağrıyı yine await etmek
TypeScript ArraymapflatMap
Promisethen içinde düz değer döndürmekthen içinde Promise döndürmek
RxJS ObservablemapmergeMap, switchMap, concatMap

Promise.then'in iki rolü birden üstlenmesi (callback düz değer döndürse de Promise döndürse de sonuç tek katmanlı Promise) bu tabloda ilginç bir ayrıntı: JavaScript flatten'ı otomatik yapıyor. RxJS'teki üç flatten operatörünün farkı (paralel, iptal eden, sıralı) ilerideki RxJS serisinin konusu.

TypeScript'te: undefined, ?. ve ?? Çoğu Zaman Yeter

TypeScript'te strictNullChecks açıkken string | undefined zaten derleyicinin takip ettiği bir tip; optional chaining (?.) ve nullish coalescing (??) zincirleme erişimi kısa tutuyor. Çoğu Angular component'inde ayrı bir Option tipine gerek yok:

type Customer = { readonly email: string; readonly address?: { readonly city?: string } };

const ada: Customer = { email: "ada@example.com", address: { city: "Izmir" } };
const alan: Customer = { email: "alan@example.com" };

// Çoğu zaman optional chaining ve ?? yeterli
for (const customer of [ada, alan]) {
  console.log(customer.email, "->", customer.address?.city?.toUpperCase() ?? "no city");
}

Tahmin sorusundaki belirsizlik TypeScript'te de var: Map.get hem "anahtar yok" hem "değeri undefined" için undefined döndürüyor. Bu ayrım gerçekten önemliyse küçük bir discriminated union yeterli:

export type Option<T> = { readonly kind: "some"; readonly value: T } | { readonly kind: "none" };

export const some = <T>(value: T): Option<T> => ({ kind: "some", value });
export const none: Option<never> = { kind: "none" };

export const map = <T, U>(option: Option<T>, f: (value: T) => U): Option<U> =>
  option.kind === "some" ? some(f(option.value)) : none;

export const bind = <T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U> =>
  option.kind === "some" ? f(option.value) : none;

export const getOrElse = <T>(option: Option<T>, fallback: T): T =>
  option.kind === "some" ? option.value : fallback;

export function find<K, V>(source: ReadonlyMap<K, V>, key: K): Option<V> {
  return source.has(key) ? some(source.get(key) as V) : none;
}
// VIP: oran henüz belli değil
const rates = new Map<string, number | undefined>([
  ["AUTUMN10", 0.1],
  ["VIP", undefined],
]);
console.log("get:", rates.get("VIP"), rates.get("SPRING5"), rates.get("VIP") === rates.get("SPRING5"));

// Dune katalogda var ama fiyatı henüz girilmedi
const listPrices = new Map<string, Option<number>>([
  ["9780141439587", some(59.9)],
  ["9780441013593", none],
]);
for (const isbn of ["9780141439587", "9780441013593", "9780000000000"]) {
  const nested = find(listPrices, isbn);
  const flat = bind(nested, (price) => price);
  console.log(isbn, JSON.stringify(nested), getOrElse(map(flat, (p) => p * 0.9), 0).toFixed(2));
}
$ node src/06-option.ts
ada@example.com -> IZMIR
alan@example.com -> no city
get: undefined undefined true
9780141439587 {"kind":"some","value":{"kind":"some","value":59.9}} 53.91
9780441013593 {"kind":"some","value":{"kind":"none"}} 0.00
9780000000000 {"kind":"none"} 0.00

TypeScript'te Option tipi C#'takinden daha az şey kazandırıyor: undefined zaten tipin parçası ve derleyici kontrolsüz erişimi hata olarak veriyor (uyarı değil). Kullanmaya değdiği yerler, "yok"un birden fazla anlamı olduğu yerler ve uzun bir dönüşüm zinciri. Kütüphane düzeyinde bu işi Effect gibi library'ler yapıyor; serinin kapsamı dışında, yalnızca adını anıyoruz.

Sık Düşülen Tuzaklar

Her yerde Option kullanıp NRT ile çift sistem kurmak. Bir method Customer?, diğeri Option<Customer> döndürünce ekip her çağrıda "bu hangisiydi?" diye bakmaya başlar ve iki sistem arasında dönüşüm kodu çoğalır. Makul bir sınır: DTO, entity, parametre ve public API'de NRT; core'un içindeki zincirleme hesaplamada Option. Sınırda ToOption() ile giriş, GetValueOrDefault/Match ile çıkış yap.

Value property'si ekleyip null'u geri getirmek. "Bazen gerçekten değer olduğunu biliyorum" diye public T Value => IsSome ? _value : throw ... eklemek cazip gelir. Bu, Nullable<T>.Value'nun tekrarı:

decimal? rate = null;
try
{
#pragma warning disable CS8629 // NRT burada uyarıyor; örnek için kapattık
    Console.WriteLine(rate.Value);
#pragma warning restore CS8629
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-pitfalls
json: {"isSome":true}
InvalidOperationException: Nullable object must have a value.

Value eklediğin anda Option'ın tek kazancı, yani "yoksa ne olacak?" sorusunu cevaplamadan değere ulaşamamak, kayboluyor. Ekip arkadaşları if (option.IsSome) option.Value yazmaya başlar ve kod NRT'li hâlinden daha uzun ama daha güvenli olmayan bir şeye dönüşür.

Option'ı JSON'a ya da veritabanına göndermek. Çıktının ilk satırına bak: Some(0.10m) serialize edilince {"isSome":true} oluyor. Değer private olduğu için System.Text.Json onu görmüyor ve oran sessizce kayboluyor; exception bile yok. EF Core da bu tipi bir kolona eşleyemez. Option, sistemin içinde kalan bir tip; API sınırında ve veritabanı sınırında nullable bir property'ye çevir. Gerçekten taşıman gerekiyorsa bir JSON converter yaz, ama önce DTO'da decimal? kullanmanın neden yetmediğini sor.

Option'ı parametre olarak almak. Checkout(Cart cart, Option<string> coupon) çağıran herkesi Option.Some("AUTUMN10") yazmaya zorlar. Parametrede string? kabul edip method'un içinde ToOption() demek çağıranın işini kolaylaştırır; Option en çok dönüş tipi olarak işe yarıyor.

Her FirstOrDefault'u Option'a çevirmeye kalkmak. Referans tiplerde FirstOrDefault + NRT zaten "yok"u null ile dürüstçe söylüyor. Option'a ihtiyaç, tahmin sorusundaki gibi default değerin geçerli bir değerle çakıştığı yerlerde (0, false, boş struct) ya da iç içe "yok" anlamlarında doğuyor. Kodda bunun için TryFirst adında küçük bir yardımcı da var.

Küçük Bir Deneme

★☆☆ Option<T>'ye OrElse(Func<Option<T>> alternative) ekle: kupondan kampanya çıkmazsa müşterinin üyelik kampanyasını dene. ActiveCampaign(...).OrElse(...) zincirini yaz ve Map/Bind'dan farkını açıkla.

★☆☆ Sepet satırlarında ilk basılı kitabı TryFirst ile bul. Aynı işi FirstOrDefault ile yapınca sepette hiç basılı kitap olmadığında ne dönüyor, TryFirst ne dönüyor?

★★☆ Formdan gelen adedi okuyan ParseQuantity(string text): Option<int> yaz (int.TryParse ve Where(q => q > 0)). Sonra from quantity in ParseQuantity(text) from price in listPrices.Find(isbn) ... şeklinde bir satır toplamı sorgusu kur. ISBN'in katalogda olup fiyatının girilmediği durumda ne oluyor?

★★★ Option<T>'yi struct yerine sealed class olarak yaz (Some ve None iki alt class) ve CouponDiscount'u 1.000.000 kez çağırıp GC.GetAllocatedBytesForCurrentThread() ile iki sürümün allocation'ını karşılaştır. Serinin son yazısında aynı ölçümü BenchmarkDotNet ile yapacağız.

Option "değer yok" diyor ama neden yok olduğunu söylemiyor. Bir sonraki yazıda bu bilgiyi de taşıyan tipe geçiyoruz: Result. Sipariş akışını Map, Bind, Ensure, Tap ve Match ile kuracak, railway-oriented programming'in iki raylı modelini görecek ve formdaki bütün doğrulama hatalarını tek seferde döndürmek için hataları nasıl biriktireceğimize bakacağız.