これはTypeScriptアドベントカレンダー2020の22日目の記事です。
私は普段はTypeScriptで開発をやることが多いのですが、T | nullじゃなくてOptionとして扱いたいなと思うことがちょくちょくあります。今回はfp-tsでそれらを解決していくぞという気持ちでやっていきます。
fp-ts
TypeScriptで関数型プログラミングができるライブラリで、OptionだとかEitherだとかが型が定義されていて、それを扱うための関数が多く実装されています。今日の段階ではv2.9.1までリリースされています。
fp-tsのドキュメントはこちら -> https://gcanti.github.io/fp-ts/
環境
- Node.js v14.15.0
- TypeScript v4.1.3
- fp-ts v2.9.1
導入
$ npm i fp-ts
例えば、Option型をimportするには、
import { Option } from 'fp-ts/lib/Option';
利用例
コード例を交えて、紹介していきます。
前提としてユーザを表すのオブジェクトUserと、取得済みのusersという配列を定義しておきます。
interface User {
id: string; // ユニークなID
name: string; // 名前
avatarUrl: string | null; // アバター画像へのURL
}
declare const users: Array<User>;
Option
usersから指定したidに紐づくユーザ名を取得する関数を考えます。
TypeScriptで単純に記述した場合、以下のような、存在した場合には名前を表すstringを返して、なければnullを返す関数になるかと思います。
const getUsername = (id: string): string | null => {
const user = users.find(user => user.id === id);
return user?.name ?? null;
};
fp-tsではこれをOption, Arrayを使って
import * as O from 'fp-ts/lib/Option';
import * as A from 'fp-ts/lib/ReadonlyArray';
const getUsername = (id: string): O.Option<string> => {
const find = A.findFirst((user: User) => user.id === id); // idにあったユーザを探す
const map = O.map((user: User) => user.name); // Option<User>をOption<string>にする
return map(find(users));
};
と書けます。
ReadonlyArray(またはArray)はJavaScript組み込みのArrayを操作する関数を提供してくれます。
idにマッチするユーザを取得する関数をfind、Option<User>をOption<string>に変換する関数をmapとして変数に代入しています。
これを次のようにpipeで繋いで書くこともできます。
import { pipe } from 'fp-ts/lib/pipeable';
const getUsername = (id: string): O.Option<string> => {
return pipe(
users,
A.findFirst(user => user.id === id), // usersの中で一番最初に`id`にマッチするものを探して`Option`にして返す
O.map(user => user.name) // ユーザが存在する場合、`name`に変換して返す
);
};
pipeは前の引数に与えた関数の結果が次の引数に与えた関数の引数として渡されていく感じです。(説明下手)
ここの例では、A.findFirst(...)が返した関数の引数にusersが渡されます。
続くO.map(...)にはA.findFirst(...)(users)の結果が渡され、pipeの結果として返ります。
これの呼び出し側で判別するにはO.isNoneまたは、O.isSomeを使います。
const username = getUsername('foo');
if (O.isNone(username)) {
return;
}
console.log(username.value); // fooの名前を出力する
値が存在しない場合にデフォルト値を与えてstringにするにはO.getOrElseを使います。
const name: string = pipe(
getUsername2('foo'),
O.getOrElse(() => 'John Doe')
);
console.log(name);
名前ではなくアバター画像へのURLをOptionとして取得したい場合はO.fromNullableを使って、
const getUserAvatarUrl = (id: string): O.Option<string> => {
return pipe(
users,
A.findFirst(user => user.id === id),
O.map(user => O.fromNullable(user.avatarUrl)),
O.flatten
);
};
と書くことができます。
とりあえず、現存しているnullableな型をOptionに変換したい場合は、O.fromNullableを使っておくと良いです。
O.flattenはOption<Option<string>>をOption<string>にしてくれます。
もし、avatarUrlがstringだとしても、URLとして正しくない文字列だとした場合はO.fromPredicateを使って、
// Urlとして正しいものかを返す
declare const isUrl: (str: string) => boolean;
// `avatarUrl`がURLとして正しい場合にのみsomeな`Option`を生成する
const fromAvatarUrl = O.fromPredicate(
(avatarUrl: string | null): avatarUrl is string => avatarUrl != null && isUrl(avatarUrl)
);
const getUserAvatarUrl = (id: string): O.Option<string> => {
return pipe(
users,
A.findFirst(user => user.id === id),
O.map(user => fromAvatarUrl(user.avatarUrl)),
O.flatten
);
};
と書けます。
O.fromPredicateにはA型を受け取りA is Bを返す関数を与えます。
この場合はavatarUrl is stringであればSomeとしてくれます。
Either
Eitherは、成功か失敗の結果を表すことのできる型です。
同じように、ユーザ名を取得する関数を考えてみます。
const getUsername = (id: string): E.Either<'NotFound', string> => {
return pipe(
users,
A.findFirst(user => user.id === id),
E.fromOption(() => 'NotFound' as const),
E.map(user => user.name)
);
};
E.fromOptionに値が存在しない場合の値を返す関数を与えています。
結果は、E.isLeft, E.isRightを使うことで判定することができます。
const e = getUsername('foo');
if (E.isLeft(e)) {
return;
}
console.log(e.right); // => fooの名前が出力される
E.left, E.rightを使い、Eitherを生成することもできます。
const getUsername = (id: string): E.Either<'NotFound', string> => {
const user = users.find(user => user.id === id);
if (user == null) {
return E.left('NotFound' as const);
}
return E.right(user.name);
};
Task
今度は、fetchなりaxiosなりでデータを取得する関数として
// 全てのユーザIDを取得する
declare const getUserIds: () => Promise<{ ids: Array<string> }>;
// IDを指定してユーザを取得する
declare const getUser: (id: string) => Promise<{ user: User }>;
全ユーザを取得して、Promise<Array<User>>で返す関数を考えます。
const getUsers = async (): Promise<Array<User>> => {
const ids = await getUserIds()
.then(result => result.ids);
return Promise.all(ids.map(getUser))
.then(results => results.map(result => result.user));
};
fp-tsではPromiseをTaskという型で扱うことができます。
Taskの定義は
export interface Task<A> {
(): Promise<A>
}
なので、() => Promise<A>に変えていきます。
import * as T from 'fp-ts/lib/Task';
import * as A from 'fp-ts/lib/ReadonlyArray';
import { pipe } from 'fp-ts/lib/pipeable';
const createGetUserIdsTask: () => T.Task<{ ids: Array<string> }> = () => () => getUserIds();
const createGetUserTask: (id: string) => T.Task<{ user: User }> = id => () => getUser(id);
こんな感じかと思います。
また、とりあえず無視していますが、このPromise<A>は例外を投げないことが前提です。投げる場合は後述するTaskEitherを使うのが正しいです。
const getUsersTask: T.Task<ReadonlyArray<User>> = pipe(
createGetUserIdsTask(), // idsを取得するTaskの生成
T.map(r => pipe(
r.ids,
A.map(createGetUserTask), // 各`id`から`User`を取得するTaskに変換
T.sequenceArray, // `Array<Task<T>>`を`Task<Array<T>>`にする
T.map(A.map(r => r.user)) // `user`に変換
)),
T.flatten // 二重になったTaskをフラットに
);
ArrayやTaskもmapで変換することができます。
Array<Task<T>>はT.sequenceArrayを使って、Promise.allと同じようにTask<Array<T>>に変換することができます。
awaitしてやれば、Tを取得することができます。
(async () => {
const users = await getUsersTask();
console.log(users);
})();
TaskEither
これはそのまま、Task<Either<L, R>>な型で、Promiseが解決した際にはEither<L, R>となります。
import * as TE from 'fp-ts/lib/TaskEither';
const createGetUserTask = (id: string): TE.TaskEither<'NotFound', User> => {
return TE.tryCatch(
() => getUser(id).then(r => r.user),
err => 'NotFound' as const
);
};
TE.tryCatchでTaskEitherを生成することができます。
第一引数には() => Promise<R>を、第二引数には、(err: unknown) => Lを与えます。
errの型が何なのか判定して返すのがベストだとは思いますが、今回は無視して'Not Found'に握りつぶします。
さっきと同じように、TaskEitherで、すべてのユーザを取得する。
// 失敗時には'InternalError'が返るとする
declare const createGetUserIdsTask: () => TE.TaskEither<'InternalError', Array<string>>;
const getUsersTask: TE.TaskEither<'NotFound' | 'InternalError', ReadonlyArray<User>> = pipe(
createGetUserIdsTask(),
TE.map(userIds => pipe(
userIds,
A.map(createGetUserTask),
TE.sequenceArray
)),
TE.flatten
);
TaskEitherでもsequenceArrayが使えます。
Taskと同じように、awaitしてEitherを取得して扱います。
(async () => {
const e = await getUsersTask(); // Either<'NotFound' | 'InternalError', ReadonlyArray<User>>
if (E.isLeft(e)) {
console.error(e.left);
return;
}
console.log('users => ');
console.log(e.right);
})();
次のような二つのTaskEitherを一度に取得したい場合は、TE.bind, TE.bindTo, TE.bindWを使って指定したキー名に結果をまとめていくことができます。
interface Post {
text: string;
}
// idを元にユーザを取得するタスクを生成する
declare const getUser: (id: string) => TE.TaskEither<'NotFound', User>;
// idを元にユーザに紐づく投稿を取得するタスクを生成する
declare const getPosts: (id: string) => TE.TaskEither<'Empty', Array<Post>>;
// 全ての投稿を持つUser
interface UserWithPosts extends User {
posts: Array<Post>;
}
// ユーザとそれに紐づく投稿をすべて取得する
const getUserWithPosts = (id: string): TE.TaskEither<'NotFound' | 'Empty', UserWithPosts> => {
return pipe(
getUser(id),
TE.bindTo('user'), // TE<_, { user: User }>
TE.bindW('posts', () => getPosts(id)), // TE<_, { user: User; posts: Array<Post> }>
TE.map(({ user, posts }) => ({ posts, ...user }))
);
};
最初のTE.bindToではgetUserの結果をuserに、次のTE.bindWではgetPostsの結果をpostという名前のキーに束縛しています。
TE.bindを使っていないのはLeftの型が異なるためで、TE.bindWは異なるLeftのE1, E2をE1 | E2として返してくれます。
これらの関数は、OptionやEitherなどにもあります。(OptionにはbindWはない)
他にも多数の関数が提供されていますし、fp-ts-contribのようなfp-tsを拡張(?)できるパッケージもあります。これらは今回私が書いたコードをより簡潔なものを提供してくれるかはわからないですが、fp-tsに組み合わせられるものがいくつかあり良い具合です。以上です。