TypeScript のエラーハンドリングに Either Monad は有効なのか?


はじめに

TypeScript における例外のハンドリングには様々な実装方法があるかと思いますが、言語として想定されているのは主に例外を投げるパターンと undefinednull とのユニオンを使うパターンがあるかと思います。

例外を投げるパターンでは throw new Error("message") というように失敗した理由を一緒に投げられる一方、その関数を使う側からみると返り値の型からその関数が失敗する可能性があることを推測することができません。

一方 undefinednull とのユニオンを使うパターンでは、関数を使う側からみて失敗する可能性が分かり、なおかつ ?. 演算子や ?? 演算子など言語自体からのサポートも手厚いです。一方失敗した理由を投げることはできません。

ここで、Rust でいう Result 型、Haskell や Scala でいう Either 型を自前で実装し使うことができれば、失敗の可能性を型で表現できるだけでなく、失敗の理由も一緒に入れてあげることができるという安直なアイディアが出てきます。

果たして、TypeScript においてこのアプローチを取ることでどうなるのか、解き明かしていきましょう。

おことわり

この記事では説明のため Haskell 風味の型宣言がちょくちょく登場します。ご了承ください。

Either Monad

Either Monad の実装

まずは Either Monad が何者かということですが、失敗する可能性のある計算について、その結果と失敗の理由両方を一度に表現することができる型です。この記事では Haskell の例に倣って Either<L, R> として表現します。Either<L, R> は失敗を表すクラス Left<L> と成功を表すクラス Right<R> のユニオンとして実装されており、Monad としては Right の値を優先して使用するようになっています。こうすることで mapflatMap を使って Either の値をどんどん合成していき、最終的にひとつの計算結果を得ることができます。

さて、この Either を TypeScript でサクッと実装してみましょう。EitherMonad なので、一応基本に忠実に FunctorApplicative も実装しておきます。

functor.ts
export interface Functor<A> {
  map<B>(f: (a: A) => B): Functor<B>
}
applicative.ts
import { Functor } from "./functor";

export interface Applicative<A> extends Functor<A> {
  map<B>(f: (a: A) => B): Applicative<B>
  ap<B>(f: Applicative<(a: A) => B>): Applicative<B>
}
monad.ts
import { Applicative } from "./applicative";

export interface Monad<A> extends Applicative<A> {
  map<B>(f: (a: A) => B): Monad<B>
  ap<B>(f: Monad<(a: A) => B>): Monad<B>
  flatMap<B>(f: (a: A) => Monad<B>): Monad<B>
}

Monad までの型定義ができたら、それを使って Either, Right, Left, を実装していきます。Right は成功を表しているので受け取った値を使って計算を行いますが、Left は失敗を表しているので受け取った値は無視しています。

either.ts
import { Monad } from "./monad";

export interface Either<L, R> extends Monad<R> {
  map<B>(f: (a: R) => B): Either<L, B>
  ap<B>(f: Monad<(a: R) => B>): Either<L, B>
  flatMap<B>(f: (a: R) => Monad<B>): Either<L, B>
}

export class Right<L, R> implements Either<L, R> {
  constructor(private readonly value: R) {}

  static of<L, R>(value: R): Right<L, R> {
    return new Right(value)
  }

  flatMap<B>(f: (a: R) => Either<L, B>): Either<L, B> {
    return f(this.value)
  }
  ap<B>(f: Either<L, (a: R) => B>): Either<L, B> {
    return f.map(g => g(this.value))
  }
  map<B>(f: (a: R) => B): Either<L, B> {
    return new Right(f(this.value))
  }
}

export class Left<L, R> implements Either<L, R> {
  constructor(private readonly value: L) {}

  static of<L, R>(value: L): Left<L, R> {
    return new Left(value)
  }

  flatMap<B>(f: (a: R) => Either<L, B>): Either<L, B> {
    return new Left(this.value)
  }
  ap<B>(f: Either<L, (a: R) => B>): Either<L, B> {
    return new Left(this.value)
  }
  map<B>(f: (a: R) => B): Either<L, B> {
    return new Left(this.value)
  }
}

Either Monad を使ってみる

ここまでできたら試しに使ってみましょう。適当に学生を表す value object を作ってみます。各 value object ではバリデーションを行いその結果として Either の値を得ます。これで呼び出している側ではここで失敗が起こり、その理由が string 型として返ってくることが明らかになるので嬉しいということができるでしょう。

ここでひと工夫。コンストラクタを private にし、static なファクトリを持たせることでバリデーションを通過しないでインスタンスが作られてしまう事故を防止しています。

さて Student 型では要求する各種 value object がすでにバリデーションが行われていることを前提とするため、Either に包まれていないクラスを引数として要求するようになっています。

student.ts
import { Either, Left, Right } from "./either"

export class StudentID {
  private constructor(public readonly value: number) {}
  
  public static of(id: number): Either<string, StudentID> {
    if (id === 0) {
      return Left.of("Student ID must not be 0.")
    } else {
      return Right.of(new StudentID(id))
    }
  }
}

export class StudentName {
  private constructor(public readonly value: string) {}
  
  public static of(name: string): Either<string, StudentName> {
    if (name.length === 0) {
      return Left.of("Student name must not be empty.")
    } else {
      return Right.of(new StudentName(name))
    }
  }
}

export class Student {
  private constructor(public readonly id: StudentID, public readonly name: StudentName) {}

  public static of(id: StudentID, name: StudentName): Student {
    return new Student(id, name)
  }
}

まず ID と名前を作るところまではすぐできます。問題は Student.of を呼ぶときに発生します。Student.of :: StudentID -> StudentName -> Student という型がほしいのに、渡ってきている型は Either string StudentID -> Either string StudentName なので型が合わないのです。

function successCase() {
  const _id = StudentID.of(5)
  const _name = StudentName.of("Hoge")

  const student = Student.of(_id, _name)  // 型の不一致
}

そこで Monad が持つ値を合成できる力を使っていきます。ここで先ほど実装しておいた flatMapmap が意味を持ってくるのです。こうしてモナドを引数に取ったり返り値にしたりしない関数でも、統一的に扱うことができるのが利点です。

function successCase() {
  const _id = StudentID.of(5)
  const _name = StudentName.of("Hoge")

  const student = _id.flatMap(
    id => _name.map(
      name => Student.of(id, name)
    )
  )
  console.log(student)
  // Right {
  //   value: Student {
  //     id: StudentID { value: 5 },
  //     name: StudentName { value: 'Hoge' }
  //   }
  // }
}

function failingCase() {
  const _id = StudentID.of(6)
  const _name = StudentName.of("")

  const student = _id.flatMap(
    id => _name.map(
      name => Student.of(id, name)
    )
  )
  console.log(student)
  // Left { value: 'Student name must not be empty.' }
}

関数の持ち上げ

ただしお気づきかと思いますが、Student クラスに実装されているフィールドが増えるにつれてこの flatMap のインデントがどんどん深くなっていってしまいます。これはいつかみた callback hell と同じ状況です。大変ゆゆしき自体ですので、なんとかする必要があります。ここで lift という概念が出てきます。

Haskell においてモナドを引数にしたり返り値にしたりしていない関数を Monad の文脈に入れてあげるために「持ち上げ(lift)」という操作が行えるようになっています。関数の引数の数に応じてこうした関数群が標準で定義されています。

liftM :: Monad m => (a1 -> r) -> m a1 -> m r 
liftM2 :: Monad m => (a1 -> a2 -> r) -> m a1 -> m a2 -> m r 
liftM3 :: Monad m => (a1 -> a2 -> a3 -> r) -> m a1 -> m a2 -> m a3 -> m r 

今回は Student クラスの実装に合わせて liftM2 相当の関数を実装してみましょう。TypeScript では Haskell と違って型を指定するときも関数の仮引数名が必要なのでやや冗長になっていますが Haskell の実装と全く同じものを実装し直しています。

either.ts
export function liftEither2<L, A, B, C>(f: (a: A, b: B) => C): (a: Either<L, A>, b: Either<L, B>) => Either<L, C> {
  return (a: Either<L, A>, b: Either<L, B>) => a.flatMap(a1 => b.map(b1 => f(a1, b1)))
}

これをこんな感じで使ってあげるとネストが深くなることはとりあえず避けられます。

function successCase() {
  const _id = StudentID.of(5)
  const _name = StudentName.of("Hoge")

  const student = liftEither2(Student.of)(_id, _name)
}

しかし呼び出す側でこれをいちいちやってほしいというのはなかなか酷な話です。引数を入れるためのカッコがに連続になるのもやや普通な感じからは外れています。pipeline operator が実装されれば多少マシかもしれませんが、結局引数の数だけ lift 系関数を実装してあげる必要があるのはやや面倒と言えるでしょう。

なお Haskell には do 記法がありますので、そもそもインデントを深くすることなく既存の関数を Monad の文脈に引き入れてあげることができます。

student1 :: Either String Student
student1 = do
  _id <- StudentID 5
  _name <- StudentName "Hoge"
  return Student _id _name

JS/TS で同等の操作ができるのは async/await 構文だけとなっていて、Monad ならなんでも do 記法に落とし込めることを前提として作られている言語と比較してしまうと、わざわざ Monad スタイルで書く必要性がどうしても薄くなっていってしまうと思わざるを得ません。

おわりに

ここまで簡単な Either を TypeScript で実装し、仮想的なユースケースにおける使用例をみてきました。その中で、言語的に積極的な Monad に対するサポートがなされていないと、積極的な利用ははばかられるという結論に今のところ至っています。具体的には、以下の点が改良されれば Monad 的なインターフェースを積極的に利用できる可能性が開かれると考えられます。

  • pipeline operator の実装
    • 関数適用のカッコが消せるので、lift などを使っても見た目的にグロテスクになりにくい
  • Haskell 的 do 記法 / Scala 的 for 式のサポート
    • 要するに map / flatMap を実装しているデータ構造に対してネストを除去できる機能
    • これは望みが薄そう

結局のところ「返り値の型を見るだけで失敗する可能性が分かり」かつ「失敗した理由を一緒に返せる」というユースケースを簡単に満たすことが現在の TypeScript では難しいのではないでしょうか。現時点においては返り値の型で表すことを諦め、例外を使ってあげるのが無難な手段なのではないかと考える次第です。

この記事をご覧になった方で、良いアイディアをお持ちの方がいればぜひコメントにお寄せください。よろしくお願いします。