0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

fp-ts不完全ガイド part1 Either

Last updated at Posted at 2024-08-20

はじめに

この文章は、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型について解説する。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?