はじめに
この文章は、fp-tsの使い方をわかりやすく説明し、利用者を増やすために書かれている。もともとは、会社で自分しか使わないであろうライブラリ、ツールをfp-tsを利用して作成していた。作成したツールが思った以上に利用されるようになり、メンテナンスできる人を増やす必要があった。そういうわけで後輩向け、同僚向けにわかりやすいfp-tsの解説を書く必要に迫られた。
僕はfp-tsの利用者が増えてほしい。fp(関数型言語を)一度使うと、もう使わなかった頃には戻れない。それくらい便利なのだが、しかし一度始めてしまうとなかなか抜けられない。やめられなくなるのだ。そのような、どうしようもないfp中毒患者を大量生産し、転職した際などにも好きにfp系の言語やライブラリが使えるようになると嬉しい。転職をたまに考えるのだが、もういまさら関数型以外で書く気がしない。それくらい関数型言語というのは面白い。
Either
そういうわけで、part1はEitherである。
Eitherは、他の言語だとResult型と言ったりする。関数の成功と失敗を表すことができる。Rightが成功で、Leftが失敗である。(Result型だとOkとErrなどだったりする)
parseInt
まずはparseInt
について考えてみよう。JavaScriptで文字列をnumber型の値に変換する関数である。この関数は、成功したらnumber型の値を返す。失敗はしないが、parseできない場合はNaNを返す。(NaNとはNot-a-numberのことである。数字ではないという意味)
const value1 = Number.parseInt("4", 10); // -> 4
const value2 = Number.parseInt("a", 10); // -> NaN
TypeScriptでNaNはnumber型として扱われる。それをうっかり忘れていて、数字として扱ってしまったときに想定と違う動きをしてしまう。NaN + 1という処理は、型エラーにならない。1+NaNはNaNになる。そういうことのないように、parseIntを使用するときにはNaNチェックが必要になる。ただ、人間なのでうっかり忘れてしまうことがある。Either型を使用すれば、この問題が解決する。
parseIntEitherという関数を考える。
import * as E from "fp-ts/Either";
export const parseIntEither = (input: string): E.Either<string, number> => {
const result = Number.parseInt(input, 10);
// isNaNのときは、E.left("Invalid input")を返す。それ以外は、E.right(result)を返す。
return Number.isNaN(result) ? E.left("Invalid input") : E.right(result);
};
const value1 = parseIntEither("42");
console.log(value1); // { _tag: 'Right', right: 42 }
const value2 = parseIntEither("abc");
console.log(value2); // { _tag: 'Left', left: 'Invalid input' }
これだけでは有用性がわからないかもしれない。
Either型の値を利用する
それでは、実際に数字を足してみよう。Either型の値は、たとえばその値がnumberだったとしても、そのまま足し算などはできない。いろいろな方法で数字として扱うことができるようになる。まずはもっとも簡単なgetOrElseから見てみよう。
const rightValue = parseIntEither("42");
const value = E.getOrElse(() => 0)(rightValue);
const result = value + 100;
console.log({ result }); // { result: 142 }
const leftValue = parseIntEither("abc");
const value3 = E.getOrElse(() => 0)(leftValue);
const result2 = value3 + 100;
console.log({ result2 }); // { result2: 100 }
E.getOrElseは、Leftだったときにデフォルトの値を取り出すことが出来る。rightValueは42でparseに成功するので、100を足したら142になる。leftValueはabcでparseに失敗するため、デフォルト値の0になる。100を足したら100である。
他にもEitherを処理する方法がある。それはif文を使う方法である。
const process = () => {
const rightValue = parseIntEither("42");
if (E.isLeft(rightValue)) {
return;
}
const add100Value = rightValue.right + 100;
console.log(add100Value); // 142
};
E.isLeft, E.isRightがある。今回は関数の中で早期リターンを行い、それ以後ではrightValue.righで値を取り出すことができる。当然ながら三項演算子を使って同様のこともできる。
これくらいの処理であれば、わざわざEither型を導入せずとも自分でparseIntのwrapperを書けば良いかもしれない。isNaNでparse後の値を判定して、NaNのときは0を返すようにすれば良い。
Either型は、失敗する可能性のある関数を表現するのに便利である。
JSONのparse
似た例だが、次はJSONをparseする関数を考えてみよう。JSON.parseは例外を投げる。しかし、例外をEitherとして処理をつづけることもできる。
import * as E from "fp-ts/Either";
const parseJsonEither = (jsonString: string): E.Either<string, unknown> =>
E.tryCatch(
() => JSON.parse(jsonString),
(reason) => `ERROR:parseJsonEither__reason:${String(reason)}`,
);
const objStr = `{"name": "John", "age": 30}`;
const obj = parseJsonEither(objStr);
console.log(obj); // { _tag: 'Right', right: { name: 'John', age: 30 } }
const invalidObjStr = "This is not a valid JSON string";
const invalidObj = parseJsonEither(invalidObjStr);
console.log(invalidObj);
/*
{
_tag: 'Left',
left: 'ERROR:parseJsonEither__reason:SyntaxError: Unexpected token T in JSON at position 0'
}
*/
parseJsonEither関数はstringを引数に取り、Eitherを返す。parse成功時はunknown、失敗したときにはerrorの理由をstringで返してくれる関数だ。E.tryCatchは2つの無名関数を取る。1つ目は、例外を投げる可能性のある処理。2つ目は、例外を投げたときの処理である。たとえば、redisなどのデータベースにstringで無理やりJSONとして保存されていたとしよう。そのJSONを取得してからparseする。そこでparseに失敗したら例外を発生せずに処理をつづけたい。そういうときに便利である。
それはtry catch節でも同じことができるのではないか、というのはその通りである。同じような処理を書くと以下のようになるだろう。
const parseJsonCatch = (json: string): unknown => {
try {
return JSON.parse(json);
} catch (e) {
return `ERROR:parseJsonCatch__reason:${String(e)}`;
}
};
この関数の問題点はなんだろうか。それは、Errorが発生するのかどうか、外部からはわかりづらいことである。関数の返り値はunknownなので、結局あとでzodなどを用いてparseすることになるだろう。ただ、stringが返ってくるのかどうか、などを関数を利用する側からはわかりづらい。Either型は、この関数はErrorが発生するかもしれない、ということを利用者に教えてくれるのが利点である。
pipe
次はpipe処理について考えてみよう。pipeというのは、ある値があったとき、その値に順々に関数を適用していく処理だ。似ているけれど少し違うのが、JavaScriptの配列に存在するメソッドチェーンである。まずは簡単なメソッドチェーンの例を見てみよう。
const numbers = [1, 2, 3, 4, 5].map((n) => n * 2).filter((n) => n > 5);
console.log(numbers); // [ 6, 8, 10 ]
これがメソッドチェーンである。1,2,3,4,5という数字の配列に、与えられた数字を2倍にする関数を適用し、次に5より大きい数字にfilterする関数を適用している。似たようなことがfp-tsでもできる。少しEitherから話は逸れるけれど、fp-tsのArrayで、pipeを使ってメソッドチェーンと同じようなことができる。
import * as A from "fp-ts/Array";
const result = pipe(
[1, 2, 3, 4, 5],
A.map((n) => n * 2),
A.filter((n) => n > 5),
);
console.log(result); // [ 6, 8, 10 ]
少し記述量が増えているけれども、このように処理が可能なのである。かなり端折った説明をすると、メソッドチェーンはオブジェクト指向プログラミング(OOP)スタイルで、pipeは関数型プログラミング(FP)のスタイルである。ここまでは、まだpipe処理の便利さがわかりづらいかもしれない。
pipe処理は配列だけではなく、Either型の際にも使用できる。すでにgetOrElseとif文で処理する方法を説明したが、次はmapを使う方法を紹介する。
map関数を用いたEitherの処理
先にmapについて軽く説明する。mapとは、簡単に言うと関数を適用することである。たとえば配列にmapを使用すると、配列の要素ひとつずつに順番に関数を適用することができる。標準のJavaScriptでは配列のメソッドチェーンとして搭載されているが、fp-tsを利用することでEither型でもmapを使用することができる。FPではファンクタ(関手)と呼ぶことを記憶の片隅においておくと良いかもしれない。
mapを使った処理は、以下のようになる。
const parseIntEither = (input: string): E.Either<string, number> => {
const result = Number.parseInt(input, 10);
return Number.isNaN(result) ? E.left("Invalid input") : E.right(result);
};
const result = pipe(
"42",
parseIntEither, // parseIntEitherに成功するとRightに値が入り、失敗するとLeftにエラーメッセージが入る
E.map((n) => n + 100), // Rightの場合のみ、値を変換し、 n + 100という関数を適用する。Leftの場合は何もしない
E.map((n) => n / 2), // 上に続いて、Rightの場合はn / 2という関数を適用する
);
console.log(result); // Right 71
const result2 = pipe(
"invalid input",
parseIntEither,
E.map((n) => n + 100), // この行は実行されない
E.map((n) => n / 2), // この行は実行されない
);
console.log(result2); // Invalid input
すでにコメントでも書いているけれど、E.mapはrightのときだけ処理をすることができる。このmapを連鎖することができる。常に成功のときだけ値を扱えるというわけだ。
それでは、もう少し実践的な例を考えてみよう。
import * as E from "fp-ts/Either";
import { pipe } from "fp-ts/function";
import { z } from "zod";
// UserSchemaを定義
const userSchema = z.object({
id: z.number(),
name: z.string(),
age: z.number(),
});
// JSON文字列をパースしてEither型に変換
const parseJsonEither = (json: string): E.Either<string, unknown> =>
E.tryCatch(
() => JSON.parse(json),
(reason) => `ERROR:parseJsonEither__reason:${String(reason)}`,
);
// Redisからユーザーリストを取得する関数(ダミー)
const getUserListFromRedis = (): string => {
const fromRediString = `[
{"id": 1, "name": "John", "age": 30},
{"id": 2, "name": "Jane", "age": 25}
]`;
return fromRediString;
};
const result = pipe(
getUserListFromRedis(), // Redisからユーザーリストを取得
parseJsonEither, // JSON文字列をパースしてEither型に変換。失敗した場合はLeftにエラーメッセージが入る。leftのときは処理が止まり、flatMapの処理は実行されない
E.flatMap((unknownJson) =>
E.tryCatch(
() => userSchema.array().parse(unknownJson),
(reason) => `ERROR:parseUserSchema__reason:${String(reason)}`,
),
), // unknown型のJSONをUserSchemaを用いてparseを試みる。失敗した場合はLeftにエラーメッセージが入る
);
pipeの流れを追ってみよう。最初にgetUserListFromRedis関数が呼ばれる。実際のDBアクセスの際はTE型というものが使われるのだが、今回は説明のために簡略化する。絶対に失敗しない、userListを文字列で返す関数である。その文字列をparseJsonEitherでparseする。失敗したら、そこでpipeの処理は終了する。rightだった場合は、flatMapが実行される。flatMapとは、map処理のなかでさらにEitherを返せるものである(言語によってはchainとも言う。chainはflatMapのエイリアスとなっている)。少しややこしいのだが、今回はflatMapのなかでさらにEitherの処理が行われている。これはzodSchemaを利用してparseしている。tryCatchでparseに成功すればright, 失敗したらleftが返ってくる。もしflatMapではなくmapを使用すると、そのときのTypeScriptの型は
const result: E.Either<string, E.Either<string, {
id: number;
name: string;
age: number;
}>>
このようになってしまう。Eitherのrightのなかに、さらにEitherがあるという非常にややこしく、使いづらい型になる。だから、入れ子になったEitherをflatにしてくれるflatMapを使用するのである。このあともmapを連鎖することができる。たとえば、抽出したユーザーリストから20歳以上に絞り込むと、以下のようになる。
const result = pipe(
getUserListFromRedis(),
parseJsonEither,
E.flatMap((unknownJson) =>
E.tryCatch(
() => userSchema.array().parse(unknownJson),
(reason) => `ERROR:parseUserSchema__reason:${String(reason)}`,
),
),
E.map((userList) => userList.filter((user) => user.age >= 20)), // UserSchemaのparseに成功した場合、ageが20以上のユーザーのみを抽出
E.mapLeft((err) => {
sendSlackError(err); // エラーが発生した場合、Slackにエラーメッセージを送信
}),
);
ついでにmapLeftについても説明する。これは通常のmapの逆で、Leftだったときに実行される関数である。たとえばsendSlackErrorという関数を定義しておいて、失敗したときにslackにエラーを送信したりできる。
仮に手続き型で書くと、以下のようになる。
const process = () => {
const redisString = getUserListFromRedis();
const jsonParsed = JSON.parse(fromRedisString);
const schemaParsed = userSchema.array().parse(jsonParsed);
const filtered = schemaParsed.filter((user) => user.age >= 20);
}
ただparseするだけなのに一時的な変数名を考えなければならない。さらにErrorがどこで発生するかわからない。エラー処理も別で書かなければならないが、try catchで捕まえる必要がある。どこでエラーが発生したのかはスタックトレースを追う必要がある。Either型であれば、leftの情報にどこでエラーが発生したのかを含めることも簡単だ。たとえばparseに失敗したときの入力値を出力するようにもできる。
いままでにEitherを利用する方法として、getOrElse, if文, mapなどを紹介した。最後にfold(match)を紹介する。
以下のようなReactのコンポーネントを考えてみよう。
import * as E from "fp-ts/Either";
import { pipe } from "fp-ts/function";
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof userSchema>;
export const UserListView = () => {
const userListEither: E.Either<string, User> = useGetUserList();
return pipe(
userListEither,
E.fold(
(error) => <div>{error}</div>>,
(userList) => (
<div>
<h1>User List</h1>
<ul>
{userList.map((user) => (
<li key={user.id}>
{user.name}({user.age})
</li>
))}
</ul>
</div>
),
),
);
};
useGetUserListというカスタムフックがあり、どうやらEitherでLeftはstring, RightはUser[]が返ってくるようだ。このuserListをfoldで処理している。foldは2つの無名関数を取り、1つ目はLeftのときの処理、2つ目はRightのときの処理を書くことができる。foldは別の言語ではmatchということもある。実際にmatchはfoldのエイリアスとして、同じように使用することができる。余談になるが、僕はmatchをts-patternでよく使用していることもあり、正規表現のmatchでも使ったりするので、foldのほうが他のmatchと混ざらないので認知しやすいと思う。
もうひとつだけ、foldWを紹介する。実はfoldはLeftケース、Rightケースで同じ型しか返せない。foldWはLeftとRightで返り値を異なる型のものにできる。
const result = pipe(
"42",
parseIntEither,
E.foldW(
(error) => `${error}というエラーが発生した。`,
(num) => num * 2,
),
);
このとき、resultの型は string | numberとなる。
実はparseJSONについては自分で実装せずとも、fp-tsがすでに実装してくれている。parseJsonEitherの代わりにJ.parseという関数を使えば良い。
テスト
Eitherを返す関数はテストが書きやすい。例外が発生するかどうかを判定しても良いけれど、Eitherにしておけばもっと簡単に書ける。LeftになるケースとRightになるケースのどちらも想定してテストを書けば良い。関数型言語の思想を取り入れると、徐々に純粋関数へと近づいていく。そうすると参照透過になり、非常にテストが書きやすくなるのである。
fp-tsのEitherについては以上である。完全とは言い難いが、重要な部分は説明できたかと思われる。次回はOption型について解説する。