1402 文字
7 分
TypeScriptを関数型言語っぽく扱うための初歩 .flatMap編

モナドとかモノイドとかそういう話は一旦抜きにして,.map.flat,それらの合成である.flatMapが何をしているか,何がありがたいのかという話をする.

前提#

以下のようなOption型がある想定で進める. Noneならば値がないことを示し,Some(v)ならば値が存在し,.valueでアクセスできる.

type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;

interface OptionFn {
  none: () => Option<never>
  some: <T>(value: T) => Option<T>
  
  isNone: <T>(opt: Option<T>) => opt is None
  isSome: <T>(opt: Option<T>) => opt is Some<T>
}

const OptionFn = {
  none: (): Option<never> => { type: 'None' },
  some: <T>(value: T): Option<T> => ({ type: 'Some', value }),

  isNone: <T>(opt: Option<T>): opt is None => opt.type === 'None',
  isSome: <T>(opt: Option<T>): opt is Some<T> => opt.type === 'Some',
}

.map#

Optionの場合の.map関数型はこうなる.

const map = <T, U>(opt: Option<T>, fn: (v: T) => U): Option<U> => {}

fnは値をTからUに変換する関数で,Optionの中身の値に対して適用したい関数を渡す.

ただ,Option型はそもそも「値があるか分からない」という状態であったことを思い出してほしい.Noneに対してはfnを適用しないよう実装する必要がある.

const map = <T, U>(opt: Option<T>, fn: (v: T) => U): Option<U> => {
  if (OptionFn.isNone(opt)) {
    return OptionFn.none()
  }

  return OptionFn.some(fn(opt.value))
}

これでSomeの時だけfnが適用されるようになった.図に起こすとこんな感じ.

map

これで何が嬉しいかというと,値の存在を途中で確認せずとも.map関数のチェーンのみで値に対しての処理が行えるという点である.

Noneの時は処理がスキップされるので,.map関数の呼び出し側は渡される値が常にSomeであるときのことだけを考えていればよくなり,値の取り出しは最後の最後まで引っ張ることができる.

const mayFail = <T>(value: T) => Math.random() > 0.5 ? OptionFn.some(value) : OptionFn.none();

const opt1: Option<number> = mayFail(100)
const opt2: Option<number> = OptionFn.map(opt1, (v) => v * 2)
const opt3: Option<string> = OptionFn.map(opt2, (v) => v.toString()) 
//    ^? = Some("200") or None

ただ,実際こういったコードを書く場合は,一度Optionに入った値を変換していくのではなく,Optionに入った値をさらにOptionを返す関数に渡して処理を継続したいことが殆どだろう.つまり,fnの返り値がOptionになる場合である.

それを.mapだけでやろうとするとOptionのネストが起こり,こうなってしまう.

const mayFail = <T>(value: T) => Math.random() > 0.5 ? OptionFn.some(value) : OptionFn.none();

const opt1: Option<number> = mayFail(100)
const opt2: Option<Option<number>> = OptionFn.map(opt1, (v) => mayFail(v * 2))
const opt3: Option<Option<Option<string>>> = OptionFn.map(opt2, (v) => OptionFn.map(v, (x) => mayFail(x.toString())))

OptionNest

これでは不便なので,入れ子になったOptionには次の.flat関数を適用することになる.

.flat#

.flat関数は,その名の通りコンテナ1で入れ子になった値をフラットにしてくれる役割を持つ.

const flat = <T>(opt: Option<Option<T>>): Option<T> => {
  if (OptionFn.isNone(opt)) {
    return OptionFn.none();
  }

  return opt.value; 
}

これを先のネストしまくったOption型の例に当てはめると,このようになる.

const opt1: Option<number> = mayFail(100)
const opt2: Option<number> = OptionFn.flat(OptionFn.map(opt1, (v) => mayFail(v * 2)))
const opt3: Option<string> = OptionFn.flat(OptionFn.map(opt2, (v) => mayFail(v.toString())))

flat-with-map

ただ,.flat関数をそのまま使うことはあまりない.なぜなら,このような.flat.mapを組み合わせて使うパターンは頻出するため,.flat.mapを合成した.flatMap関数が定義されていることがほとんどだからだ.

.flatMap#

ということで,.flatMap関数は.flat.mapの合成関数である.

実装もこれら2つの関数を組み合わせたような実装になっている.

const flatMap = <T, U>(opt: Option<T>, fn: (v: T) => Option<U>): Option<U> => {
  if (OptionFn.isNone(opt)) {
    return OptionFn.none();
  }
  
  return fn(opt.value)
}

alt text

const opt1: Option<number> = mayFail(100)
const opt2: Option<number> = OptionFn.flatMap(opt1, (v) => mayFail(v * 2))
const opt3: Option<string> = OptionFn.flatMap(opt2, (v) => mayFail(v.toString()))
//    ^? = Some("200") or None

もし,同じような内容を.flatMapなしで書く場合は次の処理を呼ぼうとするたびに値があるかどうかをチェックして連続させる必要があり,非常に面倒である.

const opt1: Option<number> = mayFail(100)

if (OptionFn.isNone(opt1)) {
  return
}

const opt2: Option<number> = mayFail(opt1.value * 2)

// こんな感じのif文がずっと続く

このように,失敗するかもしれない処理(=Optionが入れ子になるような処理)を連続で行う場合は.flatMap関数が活躍する.

今回はOption型を例にとってみたが,Promiseを同じようにラップした型やResult型を用意しても同じように実装できる.2

「失敗しうる計算を連続させる」という処理はある程度のプログラムであれば必ずといっていいほど発生する.

そういった処理を小さな処理を行う関数の合成だけでシンプルかつ安全に実装できるのは関数型・静的型プログラミングの魅力の一つだと私は考えている.

Footnotes#

  1. Option型に限らず,Result型やPromise型など,値を何かしらでラップしている型をコンテナ型と言うことがある

  2. このように,ある条件を満たした型に適用できる共通した処理や操作のパターンこそがモナドである,という認識でいいと思う

GitHubで編集を提案