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で編集を提案