この記事は NSSOL Advent Calendar 2022 の1日目の記事です
はじめに
私が現在所属しているチームでは、フロントエンドもバックエンドも共にTypeScriptをで開発を進めており、具体的にはフロントエンドではReact、バックエンドではNestJSを使用しています。
TypeScriptで共通化させることで、TypeScriptが提供する型安全性を享受しながらも、バックエンドとフロントエンドで大きなコンテキストスイッチを発生させることなく開発を進めることができていると感じています。
ただ、実感としては、バックエンドではソフトウェアで解決したい対象となるドメインのモデルを class
で表現したり、フロントエンドのReactでは小さい関数や hooks
を集約させることでロジックを表現したりと、フロントエンドとバックエンドでのロジックの構築方法に差異を感じていました。
最近は伊藤 直也さんによる講演やJSConfでの高橋 和之さんの講演などの影響もあり、こうした際に対してどのように対処していくのか、と言うことが議論されているように感じます。
この記事で伝えたいこと
私が所属しているチームでも、関数型プログラミングを使ったDDDの実現方法を紹介している書籍「Domain Modeling Made Functional」を参考に、バックエンドに関数型プログラミングを試験的に導入しています。
この記事では、関数型プログラミングの考え方の中でも、Result型を使用したエラーハンドリングのやり方を実現していきながら、モナドの雰囲気を掴んでいこうと思います。
題材として以下のようなTODOリストの作成に焦点を当てていきます。
サンプルコードは下記のリポジトリに配置しています。
try/catchを使用したエラーハンドリング
JavaScriptは組み込みで用意されている try/catch の仕組みを使ってエラーハンドリングを行うのが一般的であり、多くのライブラリも基本的には例外を throw してくる思想になっている。
例えばTODOリストで、タスクの締切日の制約を検証する場合を考えれば、まずはどのような例外が発生しうるのかを表現するために、下記のような専用のエラーを用意する。
// 日付のフォーマットが間違っていた場合
export class DueDateFormatWrongError extends Error {
constructor(message?: string) {
super(message);
this.name = DomainError.name;
}
}
// 過去の日付が設定されてしまった場合
export class DueDateNotBePastError extends Error {
// ...
}
そして入力値の検証を行うための関数の中で、該当するエラーが発生した場合には例外を throw
する。
import { parse, isValid } from "date-fns"
export const validateDueDate = (dueDate: string) => {
const parsedDueDate = parse(dueDate, "yyyy-MM-dd", new Date());
if (!isValid(parsedDueDate)) {
throw new DueDateFormatWrongError();
}
const now = Date.now();
if (parsedDueDate.getTime() < now) {
throw new DueDateNotBePastError();
}
return parsedDueDate as DueDate;
}
TypeScriptでは関数の内部で throw
を行なったとしても、関数の型シグネチャには影響を与えないため、以下のように関数を使う側からは正常系のみを取り扱っているように見える。
// (dueDate: string) => DueDate;
export const validateDueDate = (dueDate: string) => {
// ...
}
もしも検証を行なっている時に制約に引っかかってしまい処理が失敗してしまう場合は try/catch を使用してエラーハンドリングを行う必要がある。
ただし、TypeScript では try/catch をした時にエラーの方を推論してくれず、全て unknown
と判定されてしまうため、下記のようにエラーのインスタンスを基準にハンドリングしていく必要がある。
try {
const validatedDueDate = validateDueDate(payload.dueDate);
// ...
} catch (error) {
// errorはunknown型として型推論される
if (e instanceof DueDateFormatWrongError) {
// 日付フォーマットが間違っていた時の処理
}
if (e instanceof DueDateNotBePastError) {
// 過去の日付が指定されていた時の処理
}
// どこにも合致しないエラーが throw された場合
}
型にはどのような失敗が発生しうるのかが表現されていない ため、エラーハンドリングを行うためには使用している関数内から throw
されるエラーを把握しなければならない。
実際のプロジェクトでは、ドメイン層やユースケース層、プレゼンテーション層などで明確にルールを決めることで、エラーハンドリングを行なってることが多いように感じる。
Result型を使用したエラーハンドリング
try/catch を使用せずに、かつ、処理が失敗した時にその理由も処理の呼び出し元に伝えるための方法として、 Result
型を使用する方法が存在する。
これは一言で言うと、エラーが発生した時に throw
せずに関数の返り値に含めることで、関数の型シグネチャに明示的に失敗理由を伝える方法である。
そこで以下のように処理が成功した時と失敗した時を分けるために、以下のようなシンプルな型を用意しておき、関数からはこのどちらかを返却するように設計していく。
export type Ok<T> = { ok: true, value: T }
export type Err<E> = { ok: false, error: E }
export type Result<T, E> = Ok<T> | Err<E>
実際に関数から値を返却する際には、下記のようなファクトリ関数を使用して、使いやすいようにしておく。
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
export const isOk = <T, E>(input: Result<T, E>): input is Ok<T> => input.ok
export const isErr = <T, E>(input: Result<T, E>): input is Err<E> => !input.ok
ではこれらの型を使用して、タスクの締切日の制約を検証する場合を考えていくが、まずは関数からどのような成功・失敗が返される可能性があるのかを設計する。
export type ValidateDueDateError =
| { type: "DueDateFormatWrongError" }
| { type: "DueDateNotBePastError" }
export type ValidateDueDate =
Result<DueDate, ValidateDueDateError>
try/catchの場合にはカスタムエラークラスとして表現していたものが、今は単純に失敗理由を表すオブジェクトとして表現されている。
検証用の関数では throw
していた箇所が、Result型を返すように変更されていることがわかる。
export const validateDueDate: ValidateDueDate = (dueDate) => {
const parsedDueDate = parse(dueDate, "yyyy-MM-dd", new Date());
if (!isValid(parsedDueDate)) {
// throw ではなく、失敗理由を return する
return err({ type: "DueDateFormatWrongError" })
}
const now = Date.now();
if (parsedDueDate.getTime() < now) {
// throw ではなく、失敗理由を return する
return err({ type: "DueDateNotBePastError" })
}
// そのままの値ではなく、成功したことを伝えるための構造でラップする
return ok(parsedDueDate as DueDate);
}
この関数の呼び出し側は、もう try/catch をする必要がなくなり、型シグネチャで表現されている成功・失敗のケースを必ず処理しないと行けなくなった。
const validateDueDateResult = validateDueDate(payload.dueDate);
if (isErr(validateDueDateResult)) {
// 何も処理をしないのならより上位の層にErrを返す
// 何か変換などを行う場合はここで実施する
return validateDueDateResult
}
// type narrowing よって Ok<DueDate> となっているので value にアクセスできる
validateDueDateResult.value
これで呼び出し側に失敗理由を伝えながらも、エラーハンドリングを強制させることができるようになっている。
try/catch を使用した場合の関数合成
ここまででResult型を使用した場合にどのようなエラーハンドリングを行うことになるのかを掴むことができた。
Result型を使った場合には必ずエラーの場合をハンドリングする必要が生じてしまうが、これは 関数合成 を利用して、関数を組み合わせてより大きな機能を作っていく時に困る状況が発生する。
例えば try/catch を前提として、以下の3ステップでTODOリストの入力値を検証していく関数群を考える。
- 未検証の入力値を受け取る
- 未検証の入力値を検証する
- 検証済みの値に対して、タスクステータスのような新規タスクに固有の値を付与する
type ValidateTask = (unvalidatedTask: UnvalidatedTask) => ValidatedTask;
type CreateNewTask = (validatedTask: ValidatedTask) => NewTask;
ポイントはステップ3での関数の入力値は、ステップ2の関数の出力値となっており、段々に値を引き渡していくような構造になっている点である。
try/catchを前提としている関数の場合、関数の型シグネチャには例外などは表現されていないため、以下のように正常値のみを受け取る前提で記述できる。
const validatedTask = validateTask(payload.task);
const newTask = createNewTask(validatedTask);
ここで fp-ts
や lodash
が提供している関数を合成するための関数を使用すると以下のように、入出力の型があっている関数同士を組み合わせてより大きな関数を作成することができる。
// UnvalidatedTask => NewTaskという型シグネチャとなる
const workflow = flow(
validateTask,
createNewTask,
)
const newTask = workflow(payload.task);
JavaScriptではパイプライン演算子も提案されており、将来的にはこうした関数合成がやりやすくなるはずである。
Result型を使用した場合に関数合成を行う時の課題
同じことをResult型を使用した関数で実現するのは単純にはいかない。
try/catchのときと機能的には同じことを行う関数の型シグネチャを確認すると、以下のようにそれぞれの関数は成功・失敗のどちらとの明示的に表現しているため、後続の関数との入出力の方が合っていないことがわかる。
type ValidateTask = (unvalidatedTask: UnvalidatedTask)
=> Result<ValidatedTask, ValidateTaskError>;
type CreateNewTask = (validatedTask: ValidatedTask)
=> Result<NewTask, CreateNewTaskError>;
つまり1つ前の入力値をそのまま使用するのではなく、一度失敗の場合のエラーハンドリングを行なった後で、後続の関数と連携させていかなければならない。
const validatedTask = validateTask(payload.task)
// ここで一度失敗時のハンドリングを行う
if (isErr(validatedTask)) {
return err(validatedTask.error)
}
// 成功時の場合にのみ、Result型から中身を取り出す
const newTask = createNewTask(validateTask.value)
そのため関数合成を行おうとしても、関数の入出力の型の不一致が発生してそもそものコンパイルができない状態になってしまう。
const workflow = flow(
validateTask,
createNewTask, // 1つ前の関数の出力と、この関数の入力が合わない
)
ではどうすればいいのか
関数の入出力の型が一致していないことにより関数合成ができていないので、関数同士の間に入出力を一致させるための仕組みを配置すれば良い。
具体的には以下のようになる。
- 入力が
Ok
の場合- 後続の関数に、Okの中身を取り出して引数として渡す
- 入力が
Err
の場合- 後続の関数を実行せずに、そのままエラーを返して処理を終了させる
図示すると以下のような形状になる。
これがいわゆる鉄道指向プログラミングと呼ばれているものである。
- Railway Oriented Programming
この図の中の赤色部分の処理を用意すればよく、必要なことは入力となるResult型を受け取り、出力としては後続の関数のResult型と、入力のErrの2種類である。
そのため下記のような関数の型シグネチャとして表現できる。
/**
* 図示に合わせるなら下記のようなマッピングとなる
* A: ValidatedTask
* E: ValidateTaskError
*
* B: NewTask
* F: CreateNewTaskError
*/
type AndThen = <A, B, E>(
fn: (a: A) => Result<B, E>
) => <F>(input: Result<A, F>) => Result<B, E, F>
内部の実装としては、エラーの時はそのまま受け取ったエラーを返却し、成功の時は後続の関数に値を渡してあげれば良い。
export const andThen: AndThen = (fn) => (input) => {
// エラーの場合はそのまま受け取ったものを返却する
if (isErr(input)) return input;
// 成功を受け取ったら、中身を取り出して後続の関数を実行する
return fn(input.value)
}
export const Result = {
andThen,
}
この関数を使用して関数の入力値を全てResult型に統一することで、関数合成により大きな関数を作成することが可能となる。
// 関数の入出力の型は全てResult型でと統一されているので合成可能
const workflow = flow(
Result.andThen(validateTask),
Result.andThen(createNewTask),
)
const result = workflow(ok(payload.task))
今回はResult型を返却する関数同士を合成するために andThen
という関数を用意したが、Result型を返さない場合でもこのパターンを応用できる。
結局のところ関数の入出力をResult型に統一できれば良いので、例えば入力値に対してプロパティを追加するだけの、失敗などが存在していない関数を利用する場合は下記のように作成する。
type Map = <A, B>(
fn: (a: A) => B
) => <E>(input: Result<A, E>) => Result<B, E>;
export const map: Map = (fn) => (input) => {
// エラーを受け取った場合にはそのまま返す
if (isErr(input)) return input;
// 成功を受け取った場合には、関数の返却値をResultにして返す
return ok(fn(input.value));
};
これで下記のように単純な変換処理もパイプラインに組み込むことができた
const workflow = flow(
Result.andThen(validateTask),
Result.andThen(createNewTask),
Result.map((newTask) => ({ ...newTask, a: "anotherProperty" }))
)
今回はResult型の考え方を理解するために自前で実装を進めていたが、プロジェクトで使用する場合には fp-ts
や neverthrow
などのライブラリを採用したほうがよい。
結局モナドって?
ここでモナドに関する説明をWikipediaから参照する。
関数型プログラミングにおいて、モナドはプログラムを構造化するための汎用的な抽象概念である。対応したプログラム言語では、ボイラープレート的なコードでもモナドを使って除去することが可能となる。これはモナドが、 特定の形をした計算を表すデータ型と、それに関する 生成と合成の2つの手続きを提供する ことによって実現されている。生成は任意の基本型の値をモナドに包んでモナド値を生成する手続きであり、合成はモナド値を返す関数(モナド関数)たちを合成する手続きである。[1]
今までResult型を使って処理を記述していきましたが、実はデータ型と手続きの両方とも実装を行なっていました。
- データ型
- Result型として設計した下記の構造
type Result<T, E> = Ok<T> | Err<E>
- 生成と合成の手続き
- 基本型の値をモナドに包んでモナド値を生成する
-
ok
関数やerr
関数
-
- モナド値を返す関数たちを合成する手続き
-
andThen
関数やmap
関数
-
- 基本型の値をモナドに包んでモナド値を生成する
これでモナドの正体に関して、その雰囲気を掴むことができました。
さいごに
私は業務では主にTypeScriptを使っており、Haskellなどの純粋関数型プログラミング言語に触ったことはありませんでした。
しかし、今回の記事作成や案件での取り組みを通して関数型プログラミングに興味を持ったので、TODOアプリなどのシンプルな課題を題材にしてHaskellなどの組み込みでResult型を採用している言語を使って書き味を比較してみようと思います。
圏論とかも調べてみたい。