10
1

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.

ウェブクルーAdvent Calendar 2022

Day 19

Scalaユーザーが使うfp-ts

Last updated at Posted at 2022-12-18

この記事は ウェブクルー Advent Calendar 2022 19日目の記事です。
昨日は @wc_shibawan さんの「 Jacksonデシリアライズをいい感じにしたい話 」でした。

はじめに

fp-tsは、Typescriptで関数型プログラミングにおける型や手法を導入できるライブラリです。
https://github.com/gcanti/fp-ts

今回は、『fp-tsを使い始めてみたが、実装方法が今ひとつわからない』という方に、私がfp-tsを「完全に理解した :upside_down: 」となるまでに得られた知見を何点かご紹介できればと思います。

本記事を通して、少しでも実装パターンを掴んでいただけましたら幸いです。
私自身がScalaユーザーなので、一部Scalaと対比しながら進めます。

for-yield (≒ Do構文)

Scalaでいうfor-yield的な記述は、ぜひとも導入したいものです。

scala
def getUserId(): Either[String, Int]             = ???
def getUserName(id: Int): Either[String, String] = ???

val user: Either[String, (Int, String)] = for {
  id   <- getUserId()
  name <- getUserName(id)
} yield (id, name)

とは言え、このシンタックスは当然ながらTypescriptには存在しません。
これを叶える為に、fp-tsでは独自の実装を提供しています。ありがたいですね。

typescript
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'

declare const getUserId: () => E.Either<string, number>
declare const getUserName: (id: number) => E.Either<string, string>

const user: E.Either<string, { id: number; name: string }> = pipe(
  E.Do,
  E.bind('id', () => getUserId()),
  E.bind('name', ({ id }) => getUserName(id)),
  E.map(({ id, name }) => ({ id, name })),
);
  • pipe関数の第1引数に渡す値で、処理全体の型が決まります。
    fp-ts/Optionfp-ts/Either といった型毎のmoduleにある Do を利用できます。

  • bind関数の第1引数に、処理結果を束縛する変数名を指定できます。
    後続のbindやmap関数で参照する事ができます。

非同期処理

Scalaでいう Future です。
fp-tsにはPromiseを利用した Task という型が用意されています。

interface Task<A> {
  (): Promise<A>
}

Promiseを関数でラップする形になっている為、作成時には実行されません。
Scalaの Future は即時評価で、作成と同時に処理が実行されてしまいますので、その点は異なります。

非同期処理 + Either

処理結果に成功or失敗の情報を持つ非同期処理は、多くの場面で扱われるものと思います。
Scalaなら Future[Either[A,B]] という形のものです。

fp-tsでは TaskEither を組み合わせた TaskEtiher という型が用意されています。
EitherTask で包む事で定義できます。

interface TaskEither<E, A> extends Task<Either<E, A>> {}
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';

const fetchUser: TE.TaskEither<never, { id: number; name: string; }> = () => {
  const value = E.of({ id: 1, name: 'john' });
  return Promise.resolve(value);
}

Validation

複数の Either を pipe, Do で順次処理する場合、最初に失敗したエラーが返却されます。
以下はその例です。

const validateName: (name: string) => E.Either<string, string> = (name) => pipe(
  name,
  E.fromPredicate((s) => s.length > 0, () => 'name is required.')
);

const validateAge: (age: number) => E.Either<string, number> = (age) => pipe(
  age,
  E.fromPredicate((n) => n >= 20, () => 'age must be at least 20 years old.')
);

const createUser = (name: string, age: number) => pipe(
  E.Do,
  E.bind('name', () => validateName(name)),
  E.bind('age', () => validateAge(age)),
  E.map(({ name, age }) => ({ name, age })),
)

console.log(createUser('john', 20));   // => Right<{ name: 'john', age: 20 }>
console.log(createUser('', 22));       // => Left<'name is required.'>
console.log(createUser('george', 18)); // => Left<'age must be at least 20 years old.'>
console.log(createUser('', 18));       // => Left<'name is required.'>

createUser('', 18)validateName でエラーとなり、validateAge は処理されません。
その為、全てのEitherを処理しつつ、全てのエラーの累積する事はできません。

複数のエラーを累積したい場合、Scalaの場合は Validation を利用する方法があります。
(※標準APIではありませんが、catsやscalazで提供されています)

fp-tsでも Validation を利用する事ができます。
ただし、型は用意されていませんので、わかりやすくする為に独自定義をおすすめします。
実態としては、エラーを累積させる為にLeftがArrayになった Either です。

type Validation<E, A> = E.Either<E[], A>;

Either<E, A> から Validation<E, A> に変換するヘルパーメソッドも用意しましょう。
渡された Either のLeft側をArrayでラップするだけです。

const lift: <E, A>(body: E.Either<E, A>) => Validation<E, A> = (body) => pipe(
  body,
  E.mapLeft((a) => [a]),
);

以上の準備が出来たら、下記の要領で、複数のエラーを累積させる事ができます。

const ap = E.getApplicativeValidation(A.getSemigroup<string>());
const createUser = (name: string, age: number) => Apply.sequenceS(ap)({
  name: lift(validateName(name)),
  age: lift(validateAge(age)),
});

console.log(createUser('john', 20));   // => Right<{ name: 'john', age: 20 }>
console.log(createUser('', 22));       // => Left<['name is required.']>
console.log(createUser('george', 18)); // => Left<['age must be at least 20 years old.']>
console.log(createUser('', 18));       // => Left<['name is required.','age must be at least 20 years old.']>

createUser('', 18)validateNamevalidateAge のエラーを両方保持しています。
入力フォームでのバリデーション処理など、検証によるエラー内容を一度に通知させたい場面で便利ですね。

最後に

fp-ts以外にも、Ramdapurify-ts など関数型プログラミングをサポートする為のライブラリは色々ありますので、別の機会に触ってみたいと思います :smiley:

明日は @ayuko902ayuko さんの記事です。よろしくお願いします。

10
1
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
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?