fp-ts 使ってみた

これは[TypeScriptアドベントカレンダー2020](https://qiita.com/advent-calendar/2020/typescript)の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するには、

```typescript
import { Option } from 'fp-ts/lib/Option';
```

# 利用例

コード例を交えて、紹介していきます。

前提としてユーザを表すのオブジェクト`User`と、取得済みの`users`という配列を定義しておきます。

```typescript
interface User {
  id: string; // ユニークなID
  name: string; // 名前
  avatarUrl: string | null; // アバター画像へのURL
}

declare const users: Array<User>;
```

## Option

`users`から指定した`id`に紐づくユーザ名を取得する関数を考えます。

TypeScriptで単純に記述した場合、以下のような、存在した場合には名前を表す`string`を返して、なければ`null`を返す関数になるかと思います。

```typescript
const getUsername = (id: string): string | null => {
  const user = users.find(user => user.id === id);
  return user?.name ?? null;
};
```

fp-tsではこれをOption, Arrayを使って

```typescript
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`で繋いで書くこともできます。

```typescript
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`を使います。

```typescript
const username = getUsername('foo');
if (O.isNone(username)) {
    return;
}
console.log(username.value); // fooの名前を出力する
```

値が存在しない場合にデフォルト値を与えて`string`にするには`O.getOrElse`を使います。

```typescript
const name: string = pipe(
  getUsername2('foo'),
  O.getOrElse(() => 'John Doe')
);
console.log(name);
```

名前ではなくアバター画像へのURLをOptionとして取得したい場合は`O.fromNullable`を使って、

```typescript
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`を使って、

```typescript
// 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は、成功か失敗の結果を表すことのできる型です。

同じように、ユーザ名を取得する関数を考えてみます。

```typescript
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`を使うことで判定することができます。

```typescript
const e = getUsername('foo');
if (E.isLeft(e)) {
    return;
}
console.log(e.right); // => fooの名前が出力される
```

`E.left`, `E.right`を使い、Eitherを生成することもできます。

```typescript
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なりでデータを取得する関数として

```typescript
// 全てのユーザIDを取得する
declare const getUserIds: () => Promise<{ ids: Array<string> }>;
// IDを指定してユーザを取得する
declare const getUser: (id: string) => Promise<{ user: User }>;
```

全ユーザを取得して、`Promise<Array<User>>`で返す関数を考えます。

```typescript
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の定義は

```typescript
export interface Task<A> {
  (): Promise<A>
}
```

なので、`() => Promise<A>`に変えていきます。

```typescript
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を使うのが正しいです。

```typescript
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`を取得することができます。

```typescript
(async () => {
  const users = await getUsersTask();
  console.log(users);
})();
```

## TaskEither

これはそのまま、`Task<Either<L, R>>`な型で、Promiseが解決した際には`Either<L, R>`となります。

```typescript
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で、すべてのユーザを取得する。

```typescript
// 失敗時には'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を取得して扱います。

```typescript
(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`を使って指定したキー名に結果をまとめていくことができます。

```typescript
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](https://gcanti.github.io/fp-ts-contrib/)のようなfp-tsを拡張（？）できるパッケージもあります。これらは今回私が書いたコードをより簡潔なものを提供してくれるかはわからないですが、fp-tsに組み合わせられるものがいくつかあり良い具合です。以上です。