その関数ほんとに全部わかってますか? ~Effectの実例を添えて~
こんにちは、エンジニアの籏野です。
最近、弊社エンジニアが社内向けに書いた記事で「ある関数を実行したときに捕捉すべきエラーをどのように知ればよいか?」という問いを投げていました。
この問いに対する回答として、いわゆるResult型の利用を提案してくれていたのですが、これが個人的にとても興味深いものでした。
私自身最近はRustを触っていることもあり、なんとなくResult型の存在は認識していたのですが、どのようなメリットがあるのかといったことについてはあまり理解していませんでした。
そんな中で、「関数を実行したときに捕捉すべきエラー」を型を通じて可視化するというアプローチは大変学びになりました。
今回はTypeScriptにてResult型と同様の機能を提供してくれるEffectを具体的な実装を交えながら紹介していきたいと思います。
なお、本記事で紹介する実装はすべてリポジトリにまとめていますので、興味がある方はぜひご覧ください。
Effectについて
本記事で重要なのはEffectが提供してくれるEffect
という型になります。
Effectはジェネリクスになっており、以下のように表現されます。
Effect<Success, Error, Required>
ジェネリクスで指定される値は以下のようになっています。
- Success: 処理が成功したときに返却される値
- Error: 処理が失敗したときに返却される値
- Required: 処理が依存するデータや処理の型
つまりEffectを用いると、ある関数の型を見た時に「どのような値を返すのか」「エラーが起きうるのか」「解消すべき依存関係がなにか」が一目で分かるようになるのです。
では具体的な実装をしながら、Effectの利用方法を見ていきましょう。
実装
今回は「ECサイトの検索処理」を例とします。
「検索処理」は具体的に以下の処理を行うものとします。
- リクエストパラメーターのチェック
- パラメーターを元にDBから商品情報を取得
それぞれの処理についてEffectを用いて実装していきます。
リクエストパラメーターのチェック
普通は何かしらAPI作成のためのライブラリを利用すると思いますが、本記事では簡単にするためにunknown型のパラメーターを受け取りチェックを行う関数とします。
バリデーションのためのライブラリはzodを利用しました。
EffectではEffectオブジェクトを生成するための様々な関数を用意してくれており、正常な値を返すときはEffect.succeed
、エラーを返すときはEffect.fail
を使います。
import { Data, Effect } from "effect";
import { z } from "zod";
const schema = z.object({
keyword: z.string(),
caterogy: z.enum(["category1", "category2", "category3"]),
sort: z.enum(["low", "high"]),
});
export type Params = z.infer<typeof schema>;
// Effectでエラーハンドリングを行いやすいように、Data.TaggedErrorを使ってエラーを定義
export class ValidationError extends Data.TaggedError("ValidationError")<{
message: string;
}> {
public static fromZodError(error: z.ZodError) {
return new ValidationError({
message: error.errors.map((e) => e.message).join(", "),
});
}
}
export function validateParams(params: unknown) {
const result = schema.safeParse(params);
if (!result.success) {
// エラー発生時にはEffect.failを使ってエラーを返す
return Effect.fail(ValidationError.fromZodError(result.error));
}
return Effect.succeed(result.data);
}
ここでvalidateParamsが返す型を見てみると以下のようになります。
Effect<never, ValidationError, never> | Effect<{
keyword: string;
caterogy: "category1" | "category2" | "category3";
sort: "low" | "high";
}, never, never>
このようにvalidateParamsの具体的な実装を見なくても型を参照するだけで
- 成功時はkeyword/category/sortを持ったオブジェクトを返す
- ValidationErrorが発生し得る
- 特に依存関係はない
といったことが分かります。
今回のような小さな処理であれば具体的に実装を見てもいいかもしれないですが、より複雑な処理やEffect.genの内部でまた別の処理を呼びだすような場合などには非常に便利だと感じました。
またEffect.genを使うことで、上記の型を合成させることもできます。
export function validateParamsGen(params: unknown) {
return Effect.gen(function* () {
const result = schema.safeParse(params);
if (!result.success) {
return yield* Effect.fail(ValidationError.fromZodError(result.error));
}
return result.data;
});
}
// type
Effect<{
keyword: string;
caterogy: "category1" | "category2" | "category3";
sort: "low" | "high";
}, ValidationError, never>
DBから商品情報を取得
ここではEffectを用いた依存関係の表現を実装します。
DBからの情報取得のような、外部リソースに依存するような処理はインターフェースを用いた依存関係の注入を行うことでテストのしやすいコードになります。
import { Context, Effect } from "effect";
import type { Params } from "./validate";
// データ取得のためのインターフェース
export type GetData = (params: Params) => {
id: number;
name: string;
price: number;
};
// 実装とインターフェースを紐づけるためのコンテキスト
export const GetDataContext = Context.GenericTag<GetData>("GetData");
export function getData(params: Params) {
return Effect.gen(function* (_) {
const getData = yield* _(GetDataContext);
const data = getData(params);
return data;
});
}
上記コードでは、具体的なDBからのデータ取得処理については関心がなく、任意のタイミングで依存関係を注入し実際の処理を行ったりテストを行うことができるようになっています。
先ほどと同様にEffectの型を見ると以下のようになります。
Effect<{
id: number;
name: string;
price: number;
}, never, GetData>
DBから取得する情報の型と、依存する処理がGetDataであることが一目で分かります。
処理を結合する
Effectのpipeを使うことで、先ほど作成した処理を順番に実行していくパイプラインを作成することができます。
import { Effect, pipe } from "effect";
import { validateParams } from "./validate";
import { getData } from "./getData";
export function pipeline(params: unknown) {
return pipe(validateParams(params), Effect.flatMap(getData));
}
ここでもpipelineが返すEffectの型を見てみましょう。
Effect<{
id: number;
name: string;
price: number;
}, ValidationError, GetData>
先ほど実装した2つのEffectの型が合成されて、パイプライン全体の型が生成されていることが分かります。
このように処理の内部で様々な処理が呼ばれていても、どのようなエラーを返すのか、どのような依存関係があるのかが実装を見なくてもわかるのが大きなメリットです。
エラーハンドリングを追加する
エラーが発生した場合には、エラーレスポンスを返却することを想定してエラーハンドリングを行います。
Effect.catchTagsを用いました。
import { Effect, pipe } from "effect";
import { validateParams } from "./validate";
import { getData } from "./getData";
export function pipeline(params: unknown) {
return pipe(
validateParams(params),
Effect.flatMap(getData),
Effect.catchTags({
ValidationError: (error) => {
return Effect.succeed({
error: true as const,
message: error.message,
});
},
}),
);
}
再度、Effectの型を見てみるとエラーが解消されていることが分かります。
このように型を見ることですべてのエラーが捕捉できたのかを確認することができます。
Effect<{
id: number;
name: string;
price: number;
} | {
error: true;
message: string;
}, never, GetData>
依存関係の注入を行いテストする
最後に、依存関係の注入を行いテストを行う方法を紹介します。
依存関係の注入はEffect.provideを利用します。
リクエストパラメーターを変更することでエラー発生時の挙動も確認できています。
import { Context, Effect } from "effect";
import { pipeline } from ".";
import { GetDataContext } from "./getData";
function mockedPipeline(params: unknown) {
return pipeline(params).pipe(
Effect.provide(
Context.make(GetDataContext, () => {
return {
id: 1,
name: "test",
price: 100,
};
}),
),
);
}
it("正常時", async () => {
const result = await Effect.runPromise(
mockedPipeline({ keyword: "test", caterogy: "category1", sort: "low" }),
);
expect(result).toEqual({ id: 1, name: "test", price: 100 });
});
it("リクエストエラー", async () => {
const result = await Effect.runPromise(
mockedPipeline({ keyword: "test", caterogy: "category1", sort: "invalid" }),
);
expect(result).toEqual({
error: true,
message: "Invalid enum value. Expected 'low' | 'high', received 'invalid'",
});
});
モックしたパイプラインの型を見ると以下のようになります。
依存関係も解消されたことが確認できます。
Effect<{
id: number;
name: string;
price: number;
} | {
error: true;
message: string;
}, never, never>
最後に
今回はEffectを用いて関数の型を通じたエラーの可視化や依存関係の表現方法を紹介しました。
説明のため簡易な処理を対象にしましたが、実際のアプリケーションではより多くの関数に依存した処理や様々なエラーが発生すると思います。
そのような場合でも型を見て処理の概要が見通せることで、開発体験を向上させることができると感じました。
最後まで読んでいただきありがとうございました。
この記事を書いた人
籏野 拓
2018年新卒入社
昨年生まれた子どもからの夜間オンコールが少しずつ減ってきて成長を感じます。
Discussion