TypeScriptで始める簡単モナド入門

◆ Live配信スケジュール ◆
サイオステクノロジーでは、Microsoft MVPの武井による「わかりみの深いシリーズ」など、定期的なLive配信を行っています。
⇒ 詳細スケジュールはこちらから
⇒ 見逃してしまった方はYoutubeチャンネルをご覧ください
【5/21開催】Azure OpenAI ServiceによるRAG実装ガイドを公開しました
生成AIを活用したユースケースで最も一番熱いと言われているRAGの実装ガイドを公開しました。そのガイドの紹介をおこなうイベントです!!
https://tech-lab.connpass.com/event/315703/

はじめに

こんにちは、SIOS アプリケーションコンサルティングGの池田 透です。

この記事ではTypeScriptのfp-tsという関数型プログラミングのためのライブラリを使ってモナドについて説明してみます。
モナドに関する説明は数多くありますが、とても高尚な概念の説明だったり、逆にマニアックにモナドのテクニックを説明するものが多くとっつきにちょうど良いものがなかなかないんですね。

そこでTypeScriptでプログラミングをしていたらモナドを使いたくなる(かもしれない)例を使って動機付けしながら説明していきたいと思います。

tl;dr

商品IDと数量から請求明細を作る例を考えます。請求明細を作るステップとして1. 商品の検索 2. 小計の計算などが考えられます。
このステップそれぞれで「商品が存在しない場合」、「小計が0円未満になった」とエラーを出すのですが、throwif文が挟み込まれるために処理のフローがわかりづらくなります。
そこでモナドを導入することでエラーと上手に付き合いながら正常のフローを記述します。モナドを使うことで正常部分のみに着目してコードを書くことができます。

(モナドを使わないとそのように書けないという主張ではありません。一つの解決策としてモナドを取り上げています)

レシートの明細行を作ろう

抽象的に説明しても分かりづらいので今回は商品を買ってその請求明細を計算するという例を考えてみることにします。

成功パターンのみ実装してみる

まずは商品から考えてみます。

type Product = {
  id: nubmer, // 商品ID,
  name: string, // 商品名
  price: number // 価格
}

とてもシンプルですね。実際に商品を考えてみます。

const apple: Product = {
  id: 1,
  name: 'apple',
  price: 100
}

次に複数の商品データを管理するリポジトリを作ります。

class RroductReposity {
  private constructor () {}
  private static products: Product[] = [
    { id: 1, name: 'apple', price: 100 },
    { id: 2, name: 'orange', price: 150 }
  ]
  static findOne(name: string): Product {
    return this.products.find((p) => p.name === name)
  }
}

ここでリポジトリのインスタンスを作る意味はないのでstaticなメンバにしておくことにします。

さて、appleを10こ買った人のためにレシート請求明細行を出してあげましょう。
(今回の例では明細行を出すだけで十分説明できるので、請求明細を束ねたレシートの発行は考えません)

type Detail = {
  product: Product,
  quantity: number,
  subtotal: number
}

明細は具体的に商品をいくつ買って小計はいくらかといった情報を持ちます。

明細は商品IDとその数量によって算出します。ちょうどレジでバーコード(=ここでは商品ID)を読み込んで数量を打ち込むのに似ていますね。

const createDetail = (productId: number, quantity: number): Detail => {
  const product = productReposity.findOne(productId)
  const subtotal = product.price * quantity
  return { product, quantity, subtotal }
}

呼び出しは次のようになります。

const detail = createDetail(1, 10)

とてもシンプルですね。

例外を追加してみる

いまのところ正常系のみ書きました。createDetailにはいくつかエラーとなる場合があります。お気付きかと思いますが、findOneundefinedになる可能性があります。product.priceのところで未定義参照になってしまします。これをハンドルしてあげる必要があります。

これは例外と言えるでしょうから言語標準のthrowを使うことにしましょう。

const createDetail = (productId: number, quantity: number): Detail => {
  const product = productReposity.findOne(productId)
  if (!product) {
    throw new Error(`product (id=${productId}) is not found`)
  }
  const subtotal = product.price * quantity
  return { product, quantity, subtotal }
}

それからsubtotalが0未満の場合もエラーにしたいとしましょう。

const createDetail = (productId: number, quantity: number): Detail => {
  const product = productReposity.findOne(productId)
  if (!product) {
    throw new Error(`${productId}の商品は見つかりません。`)
  }
  const subtotal = product.price * quantity
  if (subtotal < 0) {
    throw new Error(`明細金額が0円未満になりました。`)
  }
  return { product, quantity, subtotal }
}

これで予測可能なエラーを適切に返すことができました。
ところが正常部分と例外部分が交互に規定コードの正常系のフローが追いづらくなってしました。

そこで少し関数化することを考えましょう。

const createSubtotal = (price: number, quantity: number): number => {
  const subtotal = product.price * quantity
  if (subtotal < 0) {
    throw new Error(`明細金額が0円未満になりました。`)
  }
  return subtotal
}
const findProduct = (productId: string): Product => {
  const product = productReposity.findOne(productId)
  if (!product) {
    throw new Error(`${productId}の商品は見つかりません。`)
  }
  return product
}

上のような関数を定義すると

const createDetail = (productId: number, quantity: number): Detail => {
  const product = findProduct(productId)
  const subtotal = createSubtotal(product.price, quantity)
  return { product, quantity, subtotal }
}

おお!綺麗に正常系が記述されました。しかし、ここで重要な情報が抜けてしまいした。createDetail関数の実装をみた人がどんな異常が起こること自体を把握できなくなってしまいました。
このコードを読んだ人はproductがundefinedになるかもしれないのに実装をサボっていると怒り出すかもしれません。はたまたエラーなど発生しないと早とちりしてcreateDetailを呼び出した誰かが例外を処理することを忘れてしまうかもしれません。もちろんこれは将来の自分がコードを読んだときにも言えることですね。

ではここでできることといったらなんでしょうか?
ここで処理を変えずにできることは主には以下の2つでしょう。

  1. コメントを残す
  2. エラーが投げられることがわかる関数名をつける

1の「コメントを残す」について、別の関数のエラーについてコメントを残すのは得策とはいえないでしょう。
findProductの実装を変更すれば容易にコメントは古くなり混乱を招くことになります。
関数名を変えるのはどうでしょう?findProductIfNotFoundThenErrorUnsafeFindProductとかでしょうか?
悪くはなさそうですが、シンプルさがなくなってしまいました。

この原因はthrowが関数を飛び越えて例外を投げるという大域脱出の性質が引き起こしています。かといってこの性質を出さないようにcreateDetail関数にthrowを戻すと堂々巡りになります。(この手の例外の扱いやスタイルに関する話は諸説あります。ですがここでは話が膨らみすぎるのでおいておかせてください)

引数でエラーを表してみよう

ということで根本的にアプローチを変えてみることにします。
例外が起きたときの関数が外側にその情報知らせる場合にできることは概ね以下の3つです。

  1. throwでエラーを投げる(今試した手段)
  2. コールバック関数にエラーを渡す
  3. 返却値でエラーを表現する

2のパターンはJavaScriptのライブラリやフレームワークではよく使われるバターンです。
例えば、expressというWebアプリケーションフレームワークでは以下のようにコールバック関数のnextにエラーがある場合は渡してあげます。
nextはエラーの有無を判断して次の適切な次の処理を呼び出します。

app.get("/users", (req, res, next) => {
  if (valid(req.query.token)) {
    next() // 認可OK
  } else {
    next(new InvalidTokenError()) // 認可NG
  }
})
app.get("/users", (req, res, next) => {
  // 正常処理
})
app.get("/users", (err, req, res, next) => {
  if (err instanceof InvalidTokenError) {
    // 異常処理
  }
})

expressの例はとてもうまくいっているように見えます。しかし、きっとうまく使うにはそれなりの作り込みが必要そうです。ここでは一旦やめておきましょう。先にもう少しライトな方法を試すことにしましょう。

3のパターンもよくみますね。例えば、簡単な方法としては失敗した場合にnullやundefinedを返す関数などです。
関数の返却値に例外を含める方法を特に積極的に使っている言語もあります。
例えば、多くのHaskellやScalaといった関数型言語モナドやGo言語の多値返却などです。

今回の例では[string, number]型で値の組みで返却することにしましょう。一番簡単なnullを返す方法を採用するとエラーの内容が抜けてしまいますからね。

では実際に例を書いてみましょう。

const createSubtotal = (price, quantity): [string, number] => {
  const subtotal = product.price * quantity
  if (subtotal < 0) {
    return [`明細金額が0円未満になりました。`, null]
  }
  return [null, subtotal]
}
const findProduct = (productId: string): [string, Product] => {
  const product = productReposity.findOne(productId)
  if (!product) {
    return [`${productId}の商品は見つかりません。`, null]
  }
  return [null, product]
}
const createDetail = (productId: number, quantity: number): [string, Detail] => {
  const [err1, product] = findProduct(productId)
  if (err1) {
    return [err, null]
  }
  const [err2, subtotal] = createSubtotal(product.price, quantity)
  if (err2) {
    return [err2, null]
  }
  const detail = { product, quantity, subtotal }
  return [null, detail]
}

これでたしかにちゃんとエラーが発生することを示すことができました。
エラーが出ることがちゃんとわかります。
createDetailを呼び出すとき[string, Detail]型をみた人はちゃんと例外をハンドルしてくれるでしょう。
type DetailError = stringという型エイリアスをつけて[DetailError, Detail]なんてすると意図が明確になりますね。

でも、これって最初にもどってしまったのでは。。。なんとかもっと簡単に書けないでしょうか?

Either<a, b>モナドを使ってみよう

ついにモナドが登場します。

モナドというのはメインの計算とそれ以外サブの計算を上手に組み合わせるための仕組みと言えます。ここではメインの計算=請求明細を出す処理、サブの計算=失敗時の処理となります。

実はモナドと一口にいってもいろいろな種類があります。
今回のような失敗を扱い場合はEither<a, b>を使います。Either<a, b>を使うとモナドで計算するための関数を使うことができます。ちなみにEither<a, b>はただの型ですが、このようなモナドの仕組みを備えた型のことをモナドという場合もあります。Either<a, b>のように2つの型をとる抽象的な型で、aにはエラーとなる型、bには成功の型を指定します。
今回の場合はEither<string, Detail>です。

一旦この形式で書き換えてみましょう。まずは以下をimportしてください。(各自の環境に合わせてfp-tsをインストールしてください)

import { Either, left, right, either } from "fp-ts/lib/Either"

createSubtotalfindProductを書き換えます。

const createSubtotal = (price: number, quantity: number): Either<string, number> => {
  const subtotal = price * quantity
  if (subtotal < 0) {
    return left(`明細金額が0円未満になりました。`)
  }
  return right(subtotal)
}
const findProduct = (productId: number): Either<string, Product> => {
  const product = productReposity.findOne(productId)
  if (!product) {
    return left(`${productId}の商品は見つかりません。`)
  }
  return right(product)
}

left, rightに見慣れないと少し戸惑いますが、left=失敗、right=成功のように読み替えられれば難しくないかと思います。rightには正しいの意味があるので関連付けておくと覚えやすいですね。

createDetail関数は以下のようになります。

const createDetail = (productId: number, quantity: number): Either<string, Detail> => {
  const product = findProduct(productId)
  const price = either.map(product, p => p.price)
  const subtotal = either.chain(price, price => createSubtotal(quantity, price))
  return either.map(
    sequenceT(either)(product, subtotal),
    ([product, subtotal]) => {
      return { product, subtotal, quantity }
    }
  )
}

おっと難しいですね。ただ、みていただくとわかりますがここでのエラー処理はなくなりました。
見慣れていないためギョッとするかもしれませんが、1つ1つみていけばそうでもありません。
請求明細を作る部分の関数が大きいので関数化しておきましょう。

const buildDetail = (product: Product, subtotal: number, quantity: number) => {
  return { product, subtotal, quantity }
}

すると以下のように直せます。

const createDetail = (productId: number, quantity: number): Either<string, Detail> => {
  const product = findProduct(productId)
  const subtotal = either.chain(product, product => createSubtotal(quantity, product.price))
  return either.map(
    sequenceT(either)(product, subtotal),
    ([product, subtotal]) => buildDetail(product, subtotal, quantity)
  )
}

1ステップずつみてみましょう。

const product = findProduct(productId)

これは簡単です。商品を取得しただけです。ただし、productEither<String, Product>型です。つまり、productは失敗したかもしれないし、成功したかもしれないという文脈付きの値であるという意味を持ちます。まるで値が文脈に包まれているように見えるので、このことをProductEitherに包まれていると表現することにしましょう。

さて次に小計を求める処理です。本来は下のように書きたいです。

const subtotal = createSubtotal(quantity, product.price)

ところがproductEitherに包まれた値ですからEitherから取り出さないとpriceを取得することができないんです。ここでchainという便利な関数を使います。この関数はEitherに包まれた値を取り出して関数に適用してくれるのです。

const subtotal = either.chain(product, product => createSubtotal(quantity, product.price))

この第一引数のchain(product, ...)Eitherに包まれた値です。これを第二引数の高階関数に対してEitherから取り出した値を渡してくれます。

わかりづらいので取り出された値をpureProductでかくと以下のような感じです。

const subtotal = either.chain(product, pureProduct => createSubtotal(quantity, pureProduct.price))

chainで値が取り出されてしまうと文脈が途切れてしまうのではと思うかもしれませんが、心配はいりません。productの文脈をちゃんと考慮して処理してくれます。もしproductleftだったらsubtotalleftになります。

では最後にbuildDetailです。buildDetailにはproductsubtotalの2つのEitherに包まれた値を適用しなくてはなりません。先ほどと同じように下のように適用できないかなと思う訳ですが、そうはいきません。

return either.chain(
  product, subtotal,
  (product, subtotal) => buildDetail(product, subtotal, quantity)
)

このような場合はsequenceTを使います。

sequenceT(either)(product, subtotal),
return either.chain(
  sequenceT(either)(product, subtotal),
  ([product, subtotal]) => buildDetail(product, subtotal, quantity)
)

このようにするとproductsubtotalはそれぞれ値が取り出されたものが使われます。
これで良さそうに見えますがあと1つだけ変更の必要があります。先ほどのcreateSubtotalと比較するとcreateSubtotalの戻り値はEitherなのに対して、buildDetailEitherに包まれない単純な値です。このような場合はchainではなくmapを使います。

sequenceT(either)(product, subtotal),
return either.map(
  sequenceT(either)(product, subtotal),
  ([product, subtotal]) => buildDetail(product, subtotal, quantity)
)

これで完成です。改めて全体をみてみます。

const createDetail = (productId: number, quantity: number): Either<string, Detail> => {
  const product = findProduct(productId)
  const subtotal = either.chain(product, product => createSubtotal(quantity, product.price))
  return either.map(
    sequenceT(either)(product, subtotal),
    ([product, subtotal]) => buildDetail(product, subtotal, quantity)
  )
}

文脈を上手に処理しながらかけていますね。今はステップ数が少ないので、かえって難しく感じるかもしれませんが、ステップ数が多かったり関数の呼び出しが複雑になったときにif文によるエラーハンドルをたくさん書くより見通しがよくなりそうではないでしょうか?

モナドのまとめ

最後にTypeScript風の疑似言語で重要なchain,ap,mapについてまとめます。
mが文脈でa,bは純粋な値です。amで包まれているときm<a>と書きます。

chain(m, a => m): m // 文脈に包まれた値を返す関数
ap(m, m b>): m // 関数自体に文脈に包まれている関数
map(m, a => b): m // 純粋な関数

実はモナドに関しては上記の3つの文脈の扱い方さえ覚えてしまえばあとはそのバリエーションでしかありません。先ほどのchainmapに加えてapだけです。apは関数自体が文脈に包まれている場合に使います。ちなみに厳密なことをいうとchainがモナドで、apがアプリカティブ、mapがファンクターという仕組みに由来しています。カジュアルな表現ではすべてモナドと言ってしまうことも多いと思います。

余談ですが、Javaには標準ライブラリに1.8から導入されたOptionalクラスがのメソッドにflatMapというものがあるのですがこれはモナドです。もしかしたら使ったことのある方もいるかもしれません。Optional<T>クラスにおけるflatMapは以下のように定義されています。

public  Optional flatMap(Function<? super T,Optional> mapper)

文法を無視して少し簡略に書きますと次のようになります。

Optional flatMap(Function<T, Optional> mapper)

Function<A, B>Aを引数としてBを返す関数型インターフェイスです。
T型を受け取りOptional<U>型を返すmappaerに対して、自身のオブジェクトのOptional<T>Tを取り出して適用するのです。まさにモナドのためのメソッドです。
読者の中にも多くのJava経験者がいると思いますが、このように実は身近なところでモナドのテクニックが使われているのです。

まとめ

TypeScriptで関数型スタイルのプログラミングの例をみてみました。
今回はEihter<a, b>モナドをみてきましたが、色々なものがモナドになります。
例えば、値がないかもしれないというOptionだったり、値が複数あるというArray、非同期という文脈のTask(Promise)もモナドとして扱うことができます。

JavaScript含めRamdaFlutureなど関数型プログラミングのためのライブラリがいくつかあります。

興味がある方はこれらについても調べてみると面白いです。

今後、モナドに限らず関数型プログラミングのための文法なりライブラリが多くの言語標準で搭載されていく可能性は高いと思います。すぐに実践として使えるかはわかりませんが、知識として覚えておいて損はないかと思います。

最後まで読んでいただき、ありがとうございました!

アバター画像
About 池田 透 5 Articles
2018年にサイオステクノロジーに入社。バックエンドを中心にWebアプリケーション開発を行なっている。美しい仕様・設計・コードで「世界中の人々のために、不可能を可能に。」するサービスを作りたい。
ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

2人がこの投稿は役に立ったと言っています。


ご覧いただきありがとうございます。
ブログの最新情報はSNSでも発信しております。
ぜひTwitterのフォロー&Facebookページにいいねをお願い致します!



>> 雑誌等の執筆依頼を受付しております。
   ご希望の方はお気軽にお問い合わせください!

Be the first to comment

Leave a Reply

Your email address will not be published.


*


質問はこちら 閉じる