これは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に組み合わせられるものがいくつかあり良い具合です。以上です。