見出し画像

fp-tsを使ったカスタムエラー型における型のUnion化とNarrowingの紹介

こんにちわ。nap5です。


fp-ts
を使ったカスタムエラー型における型のUnion化とNarrowingの紹介です。


前回の記事と関連です。デモはほぼ同じです。

https://note.com/zak5/n/n3682cf1ce459


前回ではValidationErrorで型をプロセスごとに定義をせず横着しておりましたが、プロセスごとにカスタムエラー型を定義するなら、こう書けますという紹介になります。


import { pipe } from "fp-ts/lib/function";
import seedrandom from "seedrandom";
import * as TE from "fp-ts/lib/TaskEither";
import * as T from "fp-ts/lib/Task";
import * as A from 'fp-ts/lib/Array'
import * as E from "fp-ts/lib/Either";

const rng = seedrandom("fixed-seed");

interface ErrorFormat {
  fn: string,
  detail: Record<string, unknown>
}

interface CustomErrorOptions extends ErrorOptions {
  cause?: ErrorFormat;
}

class CustomError extends Error {
  cause?: ErrorFormat
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
    this.cause = options?.cause
  }
}

export class CreateUserError extends CustomError {
  name = "CreateUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export class DeleteUserError extends CustomError {
  name = "DeleteUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export class NotifyToUserError extends CustomError {
  name = "NotifyToUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export type User = {
  id: number;
  name: string;
  isDeleted?: boolean;
};

const createUser = (
  formData: User
): TE.TaskEither<CreateUserError, User> =>
  rng() < 0.1
    ? TE.left(
      new CreateUserError("Failed createUser.", { cause: { fn: "createUser", detail: formData } })
    )
    : TE.right({ ...formData, isDeleted: rng() > 0.8 });

const deleteUser = (formData: User): TE.TaskEither<DeleteUserError, User> =>
  !formData.isDeleted
    ? TE.right({ ...formData, isDeleted: !formData.isDeleted })
    : TE.left(new DeleteUserError("Already deleted.", { cause: { fn: "deleteUser", detail: formData } }));


const notifyToUser = (formData: User): TE.TaskEither<NotifyToUserError, User> =>
  rng() < 0.8
    ? TE.right({ ...formData })
    : TE.left(new NotifyToUserError("Failed notification.", { cause: { fn: "notifyToUser", detail: formData } }));

type RepositoryError = CreateUserError | NotifyToUserError | DeleteUserError

const createUsers = (data: User[]) => A.sequence(T.ApplicativePar)(data.map(createUser));
const deleteUsers = (data: User[]) => A.sequence(T.ApplicativePar)(data.map(deleteUser));
const notifyToUsers = (data: User[]) => A.sequence(T.ApplicativePar)(data.map(notifyToUser));

export const demo = (data: User[]) =>
  pipe(
    data,
    createUsers,
    T.chain((results) => {
      const { left: lefts, right: rights } = A.separate(results);
      const deleteUsersTask = deleteUsers(rights);
      return pipe(
        deleteUsersTask,
        T.map((d) => [...d, ...lefts.map(E.left)]),
      ); // combined previous errors
    }),
    T.chain((results: E.Either<RepositoryError, User>[]) => {
      const { left: lefts, right: rights } = A.separate(results);
      const notifyToUsersTask = notifyToUsers(rights);
      return pipe(
        notifyToUsersTask,
        T.map((d) => [...d, ...lefts.map(E.left)]),
      ); // combined previous errors
    }),
  );


以下が今回でいうところのポイントになります。それぞれのプロセスに応じて、カスタムエラー型を定義してannotationしております。


export class CreateUserError extends CustomError {
  name = "CreateUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export class DeleteUserError extends CustomError {
  name = "DeleteUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export class NotifyToUserError extends CustomError {
  name = "NotifyToUserError" as const;
  constructor(message: string, options?: CustomErrorOptions) {
    super(message, options);
  }
}

export type User = {
  id: number;
  name: string;
  isDeleted?: boolean;
};

const createUser = (
  formData: User
): TE.TaskEither<CreateUserError, User> =>
  rng() < 0.1
    ? TE.left(
      new CreateUserError("Failed createUser.", { cause: { fn: "createUser", detail: formData } })
    )
    : TE.right({ ...formData, isDeleted: rng() > 0.8 });

const deleteUser = (formData: User): TE.TaskEither<DeleteUserError, User> =>
  !formData.isDeleted
    ? TE.right({ ...formData, isDeleted: !formData.isDeleted })
    : TE.left(new DeleteUserError("Already deleted.", { cause: { fn: "deleteUser", detail: formData } }));


const notifyToUser = (formData: User): TE.TaskEither<NotifyToUserError, User> =>
  rng() < 0.8
    ? TE.right({ ...formData })
    : TE.left(new NotifyToUserError("Failed notification.", { cause: { fn: "notifyToUser", detail: formData } }));


pipeにリフティングする際にはchainで前回までの処理結果のうちエラーのUnionをとらないとコンパイルエラーが出てしまうので、以下のようにUnionしております。

type RepositoryError = CreateUserError | NotifyToUserError | DeleteUserError


この定義したUnion型は以下のように使用します。

export const demo = (data: User[]) =>
  pipe(
    data,
    createUsers,
    T.chain((results) => {
      const { left: lefts, right: rights } = A.separate(results);
      const deleteUsersTask = deleteUsers(rights);
      return pipe(
        deleteUsersTask,
        T.map((d) => [...d, ...lefts.map(E.left)]),
      ); // combined previous errors
    }),
    T.chain((results: E.Either<RepositoryError, User>[]) => {
      const { left: lefts, right: rights } = A.separate(results);
      const notifyToUsersTask = notifyToUsers(rights);
      return pipe(
        notifyToUsersTask,
        T.map((d) => [...d, ...lefts.map(E.left)]),
      ); // combined previous errors
    }),
  );


こうすることで、何が良いかというと、以下のテストコードのようにエラーをパターンマッチで絞込み(Narrowing)できるので、リカバリ処理などを検討できる余地をデザインできます。絞り方の工夫をどう達成するかは手法はいろいろあると思います。カスタムエラー型を定義すると特定のプロセスに対する異常系のセマンティックが取れるので、エンタープライズ向けとかはよくやると思います。

import { test, expect } from "vitest";

import { User, demo } from ".";
import { map, separate } from "fp-ts/lib/Array";
import { pipe } from "fp-ts/lib/function";

import { match } from "ts-pattern";

test("Nice test", async () => {
  const data: User[] = [
    { id: 1, name: "User1" },
    { id: 2, name: "User2" },
    { id: 3, name: "User3" },
    { id: 4, name: "User4" },
    { id: 5, name: "User5" },
  ];
  const results = await demo(data)();
  const { left: errors, right: values } = separate(results);
  pipe(
    errors,
    map((d) =>
      match(d)
        .with({ name: "CreateUserError" }, (d) => {
          // Handle error when occured.
          console.log("A", d);
        })
        .with({ name: "DeleteUserError" }, (d) => {
          // Handle error when occured.
          console.log("B", d);
        })
        .with({ name: "NotifyToUserError" }, (d) => {
          // Handle error when occured.
          console.log("C", d);
        })
        .exhaustive()
    )
  );
  expect({
    summary: {
      processCount: results.length,
      okCount: values.length,
      errCount: errors.length,
    },
    detail: {
      input: data,
      values,
      errors: errors.map((d) => ({
        name: d.name,
        data: d.cause?.detail,
        fn: d.cause?.fn,
        message: d.message,
      })),
    },
  }).toStrictEqual({
    summary: {
      processCount: 5,
      okCount: 2,
      errCount: 3,
    },
    detail: {
      input: data,
      values: [
        { id: 3, name: "User3", isDeleted: true },
        { id: 4, name: "User4", isDeleted: true },
      ],
      errors: [
        {
          name: "NotifyToUserError",
          data: { id: 5, name: "User5", isDeleted: true },
          fn: "notifyToUser",
          message: "Failed notification.",
        },
        {
          name: "DeleteUserError",
          data: { id: 1, name: "User1", isDeleted: true },
          fn: "deleteUser",
          message: "Already deleted.",
        },
        {
          name: "CreateUserError",
          data: { id: 2, name: "User2" },
          fn: "createUser",
          message: "Failed createUser.",
        },
      ],
    },
  });
});


デモコードです。



簡単ですが、以上です。


この記事が気に入ったらサポートをしてみませんか?