TEKNİK YAZI / Developer Tools ve Açık Kaynak

Monad'lar: Zincirlenebilir İş Akışlarını Güvenli Kurmak

Monad kavramını JavaScript'te Maybe, Either, IO ve State örnekleriyle anlatan rehber: of ve flatMap, üç monad yasası, Promise'in sınırları ve do gösterimi.

Monad, bir değeri bağlamıyla birlikte taşıyan ve bu bağlamdaki adımları flatMap ile her seferinde aynı kurala göre zincirleyen bir tasarım desenidir. Bağlam; değerin hiç olmaması, bir hata, ileride gelecek bir sonuç ya da henüz çalıştırılmamış bir yan etki olabilir. Fonksiyonel programlamada null kontrolünü, hata yayılımını ve yan etkileri iş mantığından ayırmak için kullanılır. Kavram kategori teorisinden gelir, ama kullanmak için o teoriyi bilmek gerekmez.

Aşağıdaki örnekler JavaScript ile yazıldı. Önce monad’ı tanımlayan iki işlem ve üç yasa, ardından Maybe, Either, IO ve State ele alınıyor; sonra Promise ile dizilerin bu tanıma ne kadar uyduğu, monad transformer’lar, do gösterimi ve yasaların nasıl test edileceği geliyor.

Monad Nedir?

Bir monad iki işlemle tanımlanır:

map yalnız değeri dönüştürür (functor işlemi) ve bu ikisinden türetilebilir: m.map(f), m.flatMap(x => M.of(f(x))) ile aynı sonucu verir.

En sade örnek, değerin olabileceğini (Just) ya da olmayabileceğini (Nothing) modelleyen Maybe’dir:

// Değer olabilir (Just) ya da olmayabilir (Nothing)
class Maybe {
  constructor(isJust, value) {
    this.isJust = isJust;
    this.value = value;
  }

  // Her zaman Just üretir; null da geçerli bir değerdir
  static of(value) {
    return new Maybe(true, value);
  }

  static nothing() {
    return new Maybe(false, undefined);
  }

  // null ve undefined değerini Nothing'e çeviren ayrı giriş noktası
  static fromNullable(value) {
    return value === null || value === undefined
      ? Maybe.nothing()
      : Maybe.of(value);
  }

  isNothing() {
    return !this.isJust;
  }

  map(fn) {
    return this.isJust ? Maybe.of(fn(this.value)) : this;
  }

  flatMap(fn) {
    return this.isJust ? fn(this.value) : this;
  }

  getOrElse(defaultValue) {
    return this.isJust ? this.value : defaultValue;
  }
}

const doubled = Maybe.of(42)
  .map(x => x * 2)
  .map(x => x + 10);
console.log(doubled.getOrElse(0)); // 94

const missing = Maybe.fromNullable(null)
  .map(x => x * 2) // çalışmaz, Nothing olduğu gibi geçer
  .map(x => x + 10);
console.log(missing.getOrElse(0)); // 0

of ile fromNullable ayrımı bilinçlidir. of, null değerini de Just içine koyar; null kontrolü yalnız fromNullable içindedir. of null kontrolü yapsaydı Maybe.of(null).flatMap(f) ile f(null) aynı sonucu vermezdi ve aşağıdaki ilk yasa null için bozulurdu.

Monad Yasaları

Bir türün monad sayılması için of ve flatMap şu üç yasayı sağlamalıdır. Aşağıdaki ≡ işareti “aynı sonucu verir” anlamındadır; JavaScript’teki === nesne kimliğini karşılaştırdığı için yasaları doğrudan ifade etmez. Yorumlardaki Just(42) gösterimi isJust: true, value: 42 nesnesini anlatır.

1. Sol Birim (Left Identity)

// M.of(a).flatMap(f) ≡ f(a)
const f = x => Maybe.of(x * 2);

Maybe.of(21).flatMap(f); // Just(42)
f(21); // Just(42)

2. Sağ Birim (Right Identity)

// m.flatMap(M.of) ≡ m
const m = Maybe.of(42);

m.flatMap(Maybe.of); // Just(42), m ile aynı

3. Birleşme (Associativity)

// m.flatMap(f).flatMap(g) ≡ m.flatMap(x => f(x).flatMap(g))
const f = x => Maybe.of(x * 2);
const g = x => Maybe.of(x + 10);
const m = Maybe.of(16);

m.flatMap(f).flatMap(g); // Just(42)
m.flatMap(x => f(x).flatMap(g)); // Just(42)

Pratikteki karşılığı şudur: of zincire etkisiz bir adım ekler ve zinciri nasıl gruplarsanız gruplayın aynı sonucu alırsınız. Bu sayede uzun bir zinciri küçük fonksiyonlara bölmek davranışı değiştirmez.

Yaygın Monad Türleri

Maybe: Eksik Değer

Yukarıdaki Maybe sınıfıyla iç içe alanlara güvenli erişim:

function safeGet(obj, path) {
  return path
    .split(".")
    .reduce(
      (current, key) => current.flatMap(val => Maybe.fromNullable(val[key])),
      Maybe.fromNullable(obj)
    );
}

const user = { profile: { address: { city: "İstanbul" } } };

console.log(safeGet(user, "profile.address.city").getOrElse("Bilinmiyor")); // "İstanbul"
console.log(safeGet(user, "profile.address.country").getOrElse("Bilinmiyor")); // "Bilinmiyor"

Tek seferlik bir erişim için modern JavaScript’te user?.profile?.address?.city ?? "Bilinmiyor" yeterlidir. Maybe’nin farkı, “değer yok” durumunu fonksiyonlar arasında bir değer olarak taşıyabilmesidir.

Either: Hata Taşıma

// Left hatayı, Right başarılı sonucu taşır
class Either {
  constructor(value, isLeft = false) {
    this.value = value;
    this.isLeft = isLeft;
  }

  static left(value) {
    return new Either(value, true);
  }

  static right(value) {
    return new Either(value, false);
  }

  static of(value) {
    return Either.right(value);
  }

  map(fn) {
    return this.isLeft ? this : Either.right(fn(this.value));
  }

  flatMap(fn) {
    return this.isLeft ? this : fn(this.value);
  }

  fold(leftFn, rightFn) {
    return this.isLeft ? leftFn(this.value) : rightFn(this.value);
  }
}

function safeDivide(x, y) {
  return y === 0 ? Either.left("Sıfıra bölme") : Either.right(x / y);
}

const result = safeDivide(10, 2)
  .flatMap(x => safeDivide(x, 2))
  .map(x => x * 3);

result.fold(
  error => console.log(`Hata: ${error}`),
  value => console.log(`Sonuç: ${value}`) // "Sonuç: 7.5"
);

Maybe’den farkı, Left tarafının işlemin neden başarısız olduğunu taşımasıdır. Exception fırlatmaya göre avantajı, hata olasılığının fonksiyonun dönüş türünde görünmesidir.

IO: Yan Etkiyi Ertelemek

class IO {
  constructor(effect) {
    this.effect = effect;
  }

  static of(value) {
    return new IO(() => value);
  }

  map(fn) {
    return new IO(() => fn(this.effect()));
  }

  flatMap(fn) {
    return new IO(() => fn(this.effect()).effect());
  }

  run() {
    return this.effect();
  }
}

// Dosya işlemleri console.log ile temsil ediliyor
function readFile(filename) {
  return new IO(() => {
    console.log(`Okunuyor: ${filename}`);
    return `${filename} içeriği`;
  });
}

function writeFile(filename, content) {
  return new IO(() => {
    console.log(`Yazılıyor: ${filename}`);
    return content.length;
  });
}

// Bileşim kuruldu, henüz hiçbir yan etki çalışmadı
const program = readFile("input.txt").flatMap(content =>
  writeFile("output.txt", content.toUpperCase())
);

console.log("İşlemler hazırlandı");
program.run(); // yan etkiler yalnız burada çalışır

IO, “ne yapılacağını” tanımlamakla “ne zaman yapılacağını” ayırır. Programın saf kısmı IO değerleri üretir; yan etkiler tek bir noktada, run() çağrısında gerçekleşir.

State: Durumu Açıkça Taşımak

class State {
  constructor(runState) {
    this.runState = runState;
  }

  static of(value) {
    return new State(state => [value, state]);
  }

  static get() {
    return new State(state => [state, state]);
  }

  static put(newState) {
    return new State(() => [null, newState]);
  }

  map(fn) {
    return new State(state => {
      const [value, newState] = this.runState(state);
      return [fn(value), newState];
    });
  }

  flatMap(fn) {
    return new State(state => {
      const [value, newState] = this.runState(state);
      return fn(value).runState(newState);
    });
  }

  run(initialState) {
    return this.runState(initialState);
  }
}

// Yığın işlemleri: durum değişkende değil, zincir boyunca taşınır
function push(item) {
  return State.get().flatMap(stack => State.put([item, ...stack]));
}

function pop() {
  return State.get().flatMap(stack => {
    if (stack.length === 0) {
      return State.of(null);
    }
    const [head, ...tail] = stack;
    return State.put(tail).map(() => head);
  });
}

const stackOperations = push(1)
  .flatMap(() => push(2))
  .flatMap(() => push(3))
  .flatMap(() => pop())
  .flatMap(() => pop());

const [popped, finalStack] = stackOperations.run([]);
console.log("Çıkarılan değer:", popped); // 2
console.log("Son yığın:", finalStack); // [1]

Her adım önceki durumu alıp yeni durum döndürür; hiçbir yerde paylaşılan bir değişken değiştirilmez. Aynı zincir farklı bir başlangıç durumuyla tekrar çalıştırılabilir.

JavaScript’te Monad’a Benzeyen Yapılar

Promise

Promise then ile zincirlenir ve async/await, Haskell’in do gösterimine benzer bir sözdizimi sunar. Bu yüzden sık sık monad örneği olarak gösterilir. Ama Promise monad’a benzer, yasaları tam sağlamaz:

const thenable = { then: resolve => resolve(42) };
const f = x => Promise.resolve(typeof x);

Promise.resolve(thenable).then(f).then(console.log); // "number"
f(thenable).then(console.log); // "object"

Günlük kodda bu fark nadiren sorun çıkarır:

const fetchUser = id => Promise.resolve({ id, name: `Kullanıcı ${id}` });
const fetchPosts = userId =>
  Promise.resolve([
    { id: 1, title: "Yazı 1", userId },
    { id: 2, title: "Yazı 2", userId },
  ]);

// Promise döndüren callback'te then flatMap gibi, düz değerde map gibi çalışır
fetchUser(1)
  .then(user => fetchPosts(user.id))
  .then(posts => posts.map(post => post.title))
  .then(titles => console.log(titles)); // ["Yazı 1", "Yazı 2"]

// Aynı akış async/await ile
async function getUserPostTitles(id) {
  const user = await fetchUser(id);
  const posts = await fetchPosts(user.id);
  return posts.map(post => post.title);
}

Fark, Promise’i yasalara dayanan genel monad koduna (örneğin her monad için çalışan bir yardımcı fonksiyona) verdiğinizde ortaya çıkar.

Dizi: List Monad

Array.of birim, flatMap (ES2019) bind işlemi gibi çalışır. Fonksiyon dizi döndürdüğü sürece üç yasa sağlanır. flatMap dizi olmayan dönüş değerini tek eleman olarak kabul ettiği için bu koşulu dil değil, kodu yazan korur.

const numbers = [1, 2, 3];

// map: functor işlemi
const doubled = numbers.map(x => x * 2); // [2, 4, 6]

// flatMap: her elemandan birden çok sonuç üretip düzleştirir
const pairs = numbers.flatMap(x => numbers.map(y => [x, y]));
console.log(pairs);
// [[1,1], [1,2], [1,3], [2,1], [2,2], [2,3], [3,1], [3,2], [3,3]]

// Her adımda birden çok olası sonuç (belirlenimsiz hesaplama)
const nextSteps = x => [x + 1, x + 2, x + 3];
const outcomes = [5].flatMap(nextSteps).flatMap(nextSteps);

console.log(outcomes); // [7, 8, 9, 8, 9, 10, 9, 10, 11]

Monad Transformer’lar

Farklı monad’lar kendiliğinden iç içe zincirlenmez. Promise<Maybe<T>> üzerinde then ile ilerlerken her adımda Maybe’yi ayrıca açmanız gerekir. Transformer bu iki katmanı tek bir flatMap arkasında birleştirir. Aşağıdaki MaybeT, Promise içinde Maybe taşıyan basitleştirilmiş bir örnektir ve yukarıdaki Maybe sınıfını kullanır:

// Promise<Maybe<T>> değerini tek bir zincir gibi kullanmayı sağlar
class MaybeT {
  constructor(promise) {
    this.promise = promise;
  }

  static of(value) {
    return new MaybeT(Promise.resolve(Maybe.of(value)));
  }

  static fromNullable(value) {
    return new MaybeT(Promise.resolve(Maybe.fromNullable(value)));
  }

  // Promise sonucunu her zaman Just olarak sarar
  static lift(promise) {
    return new MaybeT(promise.then(Maybe.of));
  }

  // null veya undefined sonucu Nothing'e çevirir
  static liftNullable(promise) {
    return new MaybeT(promise.then(Maybe.fromNullable));
  }

  map(fn) {
    return new MaybeT(this.promise.then(maybe => maybe.map(fn)));
  }

  flatMap(fn) {
    return new MaybeT(
      this.promise.then(maybe =>
        maybe.isNothing() ? maybe : fn(maybe.value).promise
      )
    );
  }

  run() {
    return this.promise;
  }
}

const users = new Map([
  [1, { id: 1, name: "Ayşe" }],
  [2, { id: 2, name: "Mehmet" }],
]);
const emails = new Map([[1, "ayse@example.com"]]);

// Kullanıcı yoksa undefined döner
const fetchUser = async id => users.get(id);
// Profil her zaman döner, e-posta alanı boş olabilir
const fetchUserProfile = async userId => ({
  userId,
  email: emails.get(userId),
});

async function getEmail(id) {
  const email = await MaybeT.of(id)
    .flatMap(id => MaybeT.liftNullable(fetchUser(id)))
    .flatMap(user => MaybeT.lift(fetchUserProfile(user.id)))
    .flatMap(profile => MaybeT.fromNullable(profile.email))
    .run();

  return email.getOrElse("E-posta bulunamadı");
}

Promise.all([1, 2, 3].map(getEmail)).then(console.log);
// ["ayse@example.com", "E-posta bulunamadı", "E-posta bulunamadı"]

lift, Promise’te map olmadığı için then ile çalışır ve sonucu her zaman Just olarak sarar. Profil her zaman döndüğü için ikinci adımda o kullanılır. Kullanıcı ya da e-posta eksik olabileceği için o adımlarda liftNullable ve fromNullable kullanılır. MaybeT reddedilen (rejected) Promise’leri yakalamaz; ağ hatası gibi durumlar yine catch ile ele alınmalıdır.

Do Gösterimi ve Diğer Dillerdeki Karşılıkları

Uzun flatMap zincirleri iç içe callback’lere dönüşür. Bu yüzden birçok dil bind işlemini düz satırlar gibi yazdıran bir sözdizimi sunar: Haskell’de do bloğu, Scala’da for comprehension, F#‘ta computation expression (let!) ve C#‘ta LINQ’in sorgu sözdizimi. C#‘ta art arda iki from yan tümcesi derleyici tarafından SelectMany çağrısına çevrilir; SelectMany de flatMap’in karşılığıdır.

JavaScript’te benzer bir etki generator fonksiyonlarıyla kurulabilir:

// Generator ile do gösterimi: her yield bir flatMap adımıdır
function doM(generatorFn) {
  return function (...args) {
    const generator = generatorFn(...args);

    function step(value) {
      const { value: monad, done } = generator.next(value);
      if (done) {
        return monad;
      }
      return monad.flatMap(step);
    }

    return step();
  };
}

const calculation = doM(function* () {
  const x = yield Maybe.of(10);
  const y = yield Maybe.of(20);
  const z = yield Maybe.of(30);
  return Maybe.of(x + y + z);
});

console.log(calculation().getOrElse(0)); // 60

const safeCalculation = doM(function* () {
  const x = yield safeDivide(10, 2); // Right(5)
  const y = yield safeDivide(x, 0); // Left("Sıfıra bölme"), zincir burada durur
  const z = yield safeDivide(y, 2); // çalışmaz
  return Either.right(z * 2);
});

safeCalculation().fold(
  error => console.log(`Hata: ${error}`), // "Hata: Sıfıra bölme"
  value => console.log(`Sonuç: ${value}`)
);

Bu yaklaşım yalnız flatMap’in devam fonksiyonunu en fazla bir kez çağırdığı monad’larda (Maybe, Either, IO) doğru çalışır. Dizi gibi devamı birden çok kez çağıran yapılarda generator geri sarılamadığı için sonuç yanlış olur. Promise için bu işi zaten async/await yapar.

Tembel Değerlendirme ve Memoization

IO ve State zaten tembeldir: zincir kurulurken hiçbir şey çalışmaz, hesap run() çağrılınca yapılır. Sonucu önbelleğe almak (memoization) cazip görünebilir, ama bu bir performans iyileştirmesinden çok anlam değişikliğidir. IO her run() çağrısında efekti yeniden çalıştırır; memoize edilmiş bir sürüm efekti yalnız bir kez çalıştırır ve sonraki çağrılarda eski sonucu döndürür:

// Yukarıdaki IO sınıfının, sonucu ilk çalıştırmada saklayan türevi
class MemoizedIO extends IO {
  run() {
    if (!this.done) {
      this.result = this.effect();
      this.done = true;
    }
    return this.result;
  }
}

const now = new IO(() => Date.now());
const cachedNow = new MemoizedIO(() => Date.now());

now.run(); // her çağrıda o anki zamanı okur
cachedNow.run(); // ilk çağrıdaki zamanı tekrar tekrar döndürür

Saf ve pahalı bir hesap için bu kabul edilebilir. Zaman, dosya veya ağ gibi her çalıştırmada farklı sonuç vermesi beklenen efektlerde ise hatalı davranıştır.

Monad Yasalarını Test Etmek

Aşağıdaki basit yasa testi, seçilmiş örnek değerlerle üç yasayı yukarıdaki Maybe sınıfı üzerinde dener. Hata yoksa çıktı üretmez:

const sameMaybe = (a, b) =>
  a.isJust === b.isJust && Object.is(a.value, b.value);

const f = x => Maybe.of(x === null ? 0 : x * 2);
const g = x => (x > 100 ? Maybe.nothing() : Maybe.of(x + 10));

const samples = [0, 42, 99, null];
const monads = [...samples.map(x => Maybe.of(x)), Maybe.nothing()];

for (const a of samples) {
  console.assert(sameMaybe(Maybe.of(a).flatMap(f), f(a)), "sol birim", a);
}

for (const m of monads) {
  console.assert(sameMaybe(m.flatMap(Maybe.of), m), "sağ birim", m);
  console.assert(
    sameMaybe(
      m.flatMap(f).flatMap(g),
      m.flatMap(x => f(x).flatMap(g))
    ),
    "birleşme",
    m
  );
}

Örneklerdeki null bilinçli olarak seçildi: of null değerini Nothing’e çevirseydi Maybe.of(null).flatMap(f) Nothing, f(null) ise Just(0) olurdu ve sol birim kontrolü hata verirdi. Bu test birkaç sabit değerle çalıştığı için property-based test değildir. Aynı kontrolleri rastgele üretilmiş yüzlerce girdiyle çalıştırmak için fast-check gibi bir property-based test kütüphanesi kullanılabilir.

Sonuç

Monad; eksik değer, hata, ertelenmiş yan etki veya durum gibi bağlam taşıyan değerleri tek bir flatMap arayüzüyle zincirlemenin yoludur. Üç yasa, zinciri parçalara bölmenin davranışı değiştirmeyeceğini garanti eder. Hata yayılımı ya da eksik değer kontrolleri kod tabanında tekrar ediyorsa Maybe ve Either okunurluğu artırır; tek bir null kontrolü için ?. ve ?? daha basittir. JavaScript’in Promise ve Array türleri monad’a benzer zincirleme sunar ama yasaları her durumda sağlamaz. Tür denetimiyle çalışmak isteyenler için TypeScript’te fp-ts gibi kütüphaneler bu soyutlamaları açık türlerle modeller.

Birincil kaynaklar