HKTs は結局何が便利なのか、そして OCaml 5 がアツい理由 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

HKTs は結局何が便利なのか、そして OCaml 5 がアツい理由

エンジニアグループ、コンシューマーチームの松本と申します。関数型まつり2026も楽しかったですね!

この記事は関数型まつり2026 において、「なぜ多くの言語は Higher Kinded Types (HKTs) をサポートしないのか」という 10 分間のセッションで話しきれなかったトピックを掘り下げたものです。 本編は「HKTsをサポートしない理由」と「トレードオフ」が主軸でした。 一方、「HKTsが解決する具体的な課題」はセッションで省略したので、これを説明します。また、OCaml 5がアツい理由も併せてご紹介します。

fortee.jp

前提: HKTs とは何か(おさらい)

型コンストラクタ自体を型変数として抽象化できる機能です。Scala では F[_] のように書きます。

普通のジェネリクス(List<T>T を抽象化するもの)と違い、HKTs は「F 自体」、つまり ListOptionFuture といったコンテナ・文脈そのものを抽象化します。TypeScript や Rust にはこの機能がありません。

1. HKTs が使えると何が便利なのか

スライドでは 4 つの具体例を挙げましたが、それぞれ「TypeScript だとどう妥協するか」「Scala だと何が変わるか」を軸に、もう少し丁寧に説明します。

共通する構図

HKTs がない言語では、コードは特定の文脈(Promise や例外)に最初から結びついて書かれます。HKTs がある言語では、文脈を型変数 F[_] として外に出し、後から差し込めます。

def fetch[F[_]](id: UserId): F[User] = ...

// F = Future            → 非同期処理
// F = Either[E, *]       → 早期 return によるエラーハンドリング
// F = IO                 → 副作用を持つ実 DB アクセス
// F = Identity           → 同期・純粋(テスト用のスタブ)

同じ fetch の実装(あるいは同じシグネチャに対する呼び出し側のロジック)は、F を差し替えるだけで「本番用の非同期・副作用ありコード」にも「テスト用の同期・純粋コード」にもなります。これは単なる DI(依存性注入)ではなく、「処理の合成のされ方」自体を型で共有できるという話です。

具体例①: Validation(エラーの蓄積)

複数フィールドを検証して、最初のエラーで止めずにすべてのエラーを蓄積したいというのはよくある要件です。

TypeScript でも書けないわけではありません。配列に詰めて filterflatMap すれば実現できます。

function validateUser(name, email, age): Result<User> {
  const errors = [validateName(name), validateEmail(email), validateAge(age)]
    .filter(r => !r.ok)
    .flatMap(r => r.errors)

  return errors.length
    ? { ok: false, errors }
    : { ok: true, value: { name, email, age } }
}

問題は、これがこの関数のためだけの手書きロジックだということです。フィールドが増えるたびに、似たような集約コードを書き直すことになります。抽象化しようとしても、「Result を配列で貯める」というパターンそのものを型として表現する手段が TypeScript にはありません。

Scala では、この「複数の独立した結果を横に並べて、すべてのエラーを保ったまま合成する」という操作が Applicative という一つの型クラスとして抽象化されています。Validated はその具体例の一つに過ぎません。

(validateName(name), validateEmail(email), validateAge(age))
  .mapN(User.apply)
// → Validated[NonEmptyList[Error], User]

mapN はフィールド数が変わっても本質的に同じ形で書けます。しかも Validated だけでなく、Option, Either, IO など任意の F[_]: Applicative に対して同じ mapN が使えます。「エラーを蓄積する」という関心が、特定のデータ型から独立した再利用可能な操作として存在するのが HKTs の効能です。

具体例②: Repository 抽象化(効果の入れ替え)

「本番では非同期 DB アクセス、テストでは同期のインメモリ実装」を切り替えたい、というのも典型的な要件です。

TypeScript ではとりあえず全部 Promise に寄せて解決するのが定石です。

interface UserRepo { findById(id: UserId): Promise<User | null> }

async function lookup(repo: UserRepo, id: UserId): Promise<string | null> {
  return (await repo.findById(id))?.name ?? null
}
// テストは Promise.resolve(mockUser) で対応(await の向こう側は同じ形になる)

これはこれで実用上まったく問題ありません。await の存在によって「同期か非同期か」の違いをある程度隠蔽できるからです。

一方 Scala では、F[_] をビジネスロジックの型シグネチャに残したまま抽象化できます。

trait UserRepo[F[_]] { def findById(id: UserId): F[Option[User]] }

def lookup[F[_]: Monad](repo: UserRepo[F], id: UserId): F[Option[String]] =
  repo.findById(id).map(_.map(_.name))
// F = IO / Reader[Env, *] / ZIO[R, E, *] / Identity(テスト)/ 自作の型

ポイントは、lookup の実装が一切変わらないまま、FIO(本番の副作用)にも Identity(テストの純粋な値)にも Reader[Env, *](環境を持ち回る文脈)にも差し替えられることです。TypeScript の Promise 固定と違い、「非同期かどうか」自体が呼び出し側の選択になります。ここで F[_]: Monad という制約が効いています ── Monad を満たす任意の F に対してこの関数が動くことを、コンパイラが保証してくれます。

具体例③: Effect System(依存・エラー・戻り値を型で追跡)

TypeScript の async function getUser(id): Promise<User> は、戻り値の型だけを見ても「何に依存しているか」「どんなエラーが起こりうるか」が分かりません。それらは実装を読むか、ドキュメントを読むか、実行時に例外を踏むまで分からないままです。

Scala(ZIO のようなライブラリ)では、これらを型パラメータとして型そのものに乗せられます。

def getUser(id: UserId): ZIO[Database & Logger, NotFoundError, User]
//                            ^^^^ 依存 ^^^^        ^^ エラー ^^   ^ 結果 ^

ZIO[R, E, A] は 3 引数の型コンストラクタで、R(何を要求するか)・E(何が起こりうるか)・A(何を返すか)をすべて型シグネチャに刻みます。呼び出し側はシグネチャを見るだけで「このコードを動かすには DatabaseLogger が要る」「NotFoundError を処理しないとコンパイルが通らない」ということが分かります。これは「HKTs があるから」というより「ZIO のような 3 引数コンストラクタを型クラスの世界に持ち込める」という、より大きな枠組みの話であり、HKTs はその土台になっています。

具体例④: Parser Combinator(小さい部品を合成して文法を組み立てる)

パーサーコンビネータは、小さいパーサーを合成して大きな文法を組み立てる手法です。TypeScript では、合成のたびに戻り値の型を手で書き下ろす必要があります。

Scala では、Parser[A]Monad を満たすことさえ定義してしまえば、for-yield 構文がそのまま「パーサーを順につなげて合成する」構文になります。

val program = for {
  name  <- identifier
  _     <- char('=')
  value <- expression
} yield Assignment(name, value)

これは Parser 専用の構文ではありません。for-yield は Scala における「任意の Monad に対する汎用の合成構文」であり、Parser がその型クラスの要件を満たしているからこそ、追加のコストなしにこの構文的な恩恵を受けられます。

まとめると

HKTs が可能にしているのは、「便利な糖衣構文が使える」ことそのものではなく、「コンテナ・文脈(F[_])を跨いだ、共通の操作パターン(Applicative, Monad, Functor など)をライブラリ・エコシステム全体で共有できる」ことです。個々のケースは TypeScript でも十分に書けます。しかし「同じ形の抽象化を、異なる文脈に何度でも使い回す」ためのインフラが、HKTs のある言語には備わっています。

2. OCaml 5 が今アツい理由

Extensible Effects(Free モナド)が重い理由

具体例③で挙げた Effect Systemについてですが、HKTsを用いた一般的な実装手法には実務上の課題があります

例えば、複数の効果(Reader, State, Error など)を型レベルで積み重ねられる Extensible Effects を用いることで実現でき、 eff のようなライブラリで Scala でも実現できます。

type Stack = Fx.fx3[Reader[Config, *], State[Int, *], Error[String, *]]

def program: Eff[Stack, Result] =
  for {
    cfg <- ask[Stack, Config]
    _   <- put[Stack, Int](1)
    r   <- fromEither[Stack, String, Result](fetch(cfg))
  } yield r

一見エレガントですが、実務で使うと次の問題にぶつかります。

  • 型が伸び続ける — 効果が増えるたびに Stack の型引数リストが伸び、シグネチャを読み解くコストが跳ね上がります。
  • 実行地点が見えにくいrun... の呼び出しが呼び出し側の奥深くに隠れることが多く、「この効果は結局どこで解釈されているのか」を静的に追いにくくなります。
  • ランタイムコストが高い — この手のライブラリの内部実装は基本的に Free モナド、つまり「命令列をいったんデータ構造(木)として積み上げてから、あとでその木を解釈する」という設計になっています。命令を実行するたびに、その命令を表すオブジェクト(Bind, Pure などのノード)を新たにヒープに確保する必要があり、これが実行のたびに大量のアロケーションと GC 負荷を生みます。

つまり Free モナドは「効果を合成可能にする」という表現力を、命令列を一度データとして構築してから後で辿るという間接層を挟むことで実現しています。この間接層そのものがコストの発生源になっています。

OCaml 5 の Algebraic Effects: 間接層を挟まない

OCaml 5 で導入された Algebraic Effects(Effect Handler)は、この「命令列をデータとして積んでから解釈する」という層自体を必要としません。

type _ Effect.t += Ask : config Effect.t

let program () =
  let cfg = perform Ask in
  ...

let () =
  match program () with
  | result -> result
  | effect Ask, k -> Effect.Deep.continue k my_config

perform は効果を「発生させる」という命令であり、match ... with effect がその場でハンドラを定義します。ここで起きているのは、Free モナドのように「後で解釈するための木を組み立てる」ことではなく、ランタイムの継続 (continuation) を直接操作することです。perform Ask が呼ばれた時点で、実行はハンドラの effect Ask, k にジャンプし、k という「perform の呼び出し地点から続きを再開するための継続」を受け取ります。Effect.Deep.continue k my_config によって、その継続に値を渡してそのまま実行を再開します。

これにより、次のような利点が得られます。

  • アロケーションが激減する — 命令列を表現するためのオブジェクトを作る必要がなく、継続をランタイムが直接扱います。
  • ハンドラをコード上の一箇所に書き下せるmatch ... with effect がそのままハンドラの定義であり、Free モナドのように「効果の解釈」をライブラリの奥に隠す必要がありません 1

「Free モナドは重い」という話が出発点でしたが、では継続がめちゃくちゃ軽い機構なのかというと、そうとは言い切れません。それに、勘の良い方なら、ここまで聞くと「それって例外で良くね?」と思われたかもしれません。

では、多少のコストを払ってでも継続を使う価値はどこにあるのでしょうか。それは、継続が例外にはできないことができる機構だからです。

例外は「脱出する(元の呼び出し元には戻らず、ハンドラまで一気にジャンプする)」ことしかできません。一方、継続は perform した地点まで戻ってきて処理を再開できます。この「戻れる」という性質は、他の多くの言語にはあまり見られない特徴です。

OCaml 5 は Effect Handler を言語機能に持っている

継続簡単に扱えることが、この設計の前提になっています。JVM は Project Loom(Java 21〜)によって、仮想スレッドを支える stackful な継続機構自体はランタイムに持つようになりました。ただしこれは one-shot(一度しか resume できない)継続で、しかも言語構文としては公開されていません。Kotlin の suspend も継続渡しスタイル (CPS) へのコンパイルを利用しており、この機構の上に effect handler を実装する研究(kotlin-effects)や、JVM バイトコードを直接 CPS 変換して Free モナドを経由せずに effect handler を実装する研究(java-effekt)も存在します。

ただしこれらはいずれもライブラリ・バイトコード変換・研究プロジェクトの域に留まり、Scala や Kotlin、F# の言語標準機能としては存在しません。実務で使うなら、結局 Free モナドのような「データとして表現してから解釈する」間接層に頼るのが現実的な選択肢です。

OCaml 5 は、effect / perform / match ... with effect を言語構文として組み込み、Effect Handler を第一級の言語機能として提供しています(ちなみに OCaml の継続も one-shot であり、multi-shot 継続が安価という意味ではありません)。「副作用をどう扱うか」という問題に対して、HKTs のある言語では、その解決策の一つとして Free モナドのような間接層を挟む方法が使われることがあります。OCaml 5 は、この間接層をライブラリ側で工夫するのではなく、Algebraic Effects というまったく別の解決策を言語機能として持ち込んだ、という点が個人的に今アツいと感じている理由です。

まとめ

  • HKTs の実務的な価値は「便利な構文」ではなく、「コンテナ・文脈を跨いだ操作パターン(Applicative/Monad/Functor など)をエコシステム全体で共有できる」ことにあります。
  • Extensible Effects を HKTs で実現しようとすると、内部実装が Free モナドになりがちで、型の肥大化・実行地点の不透明さ・GC 負荷という 3 つの実務的コストを払うことになります。
  • OCaml 5 の Algebraic Effects は、この Free モナドの間接層自体を不要にしました。effect/perform を言語構文・型システムに統合したからこそ実現できた設計であり、JVM 系言語では継続機構自体はあっても言語標準機能としては存在しません。

関数型まつりへの感謝を

前身のScalaMatsuri時代から関数型まつりには様々なことを勉強させていただいていました。そのときは私はScalaを触っていたので、Scalaの勉強、として参加していました。 「関数型まつり」に間口が広がったことにより、Scala 以外の関数型言語に触れたり、もっと根本的な理論(代数的データ型、圏論等)を学ぶ機会が増えました。それにより、SMLやOCaml 等、今まで触れてこなかったML系の言語に触れることができたり、自分の中でプログラミングのパラダイムが広がっているのを感じます。

改めて、関数型まつりのスタッフの皆様、登壇者参加者に感謝を申し上げます!

We are Hiring!!

エムスリーではソフトウェアエンジニアを募集中です。関数型まつりの発表をご覧頂いて弊社に興味を持って頂いた皆様、カジュアル面談からでもぜひご応募お待ちしています。

open.talentio.com

jobs.m3.com


  1. ただし、これは「ある perform がどのハンドラに捕まるかが静的に明確になる」という意味ではありません。ハンドラの解決は実行時の呼び出しスタックを遡って動的に行われますし、OCaml には effect を型で追跡する仕組みもないため、ある関数がどんな効果を perform しうるか、それがどのハンドラで処理されるかは型シグネチャからはわかりません。「どこでハンドラを引っ掛けるか」を書き手が自分で気にする必要があり、コード規約で縛るなどの運用が必要です。これはコンパイラが検知してくれる類のものではないため、実は内部の関数がこっそり別の効果を perform していた、といったバグに気づくにはコードを読んでいくしかありません。このあたりは今後の課題です。