鯉のぼりのイラスト

エラーや非同期処理をより安全に扱うための TypeScript ライブラリ Effect-TS

Effect-TS は、開発者が複雑なエラーや非同期処理をより安全に開発できるようにすることを目的とした TypeScript ライブラリです。Effect-TS は、TypeScript の型システムを活用して、本番のアプリケーションにおける実用的な問題を解決することを目指しています。

Effect-TS(正式名称は Effect)は、開発者が複雑なエラーや非同期処理をより安全に開発できるようにすることを目的とした TypeScript ライブラリです。Effect System という概念を取り入れており、Scala や Haskell といった関数型プログラミング言語に影響を受けています。

TypeScript の型システムを活用して、本番のアプリケーションにおける実用的な問題を解決することを目指しています。Effect-TS は、以下のような特徴を備えています。

  • 並行性(concurrency):Fiber ベースの並行モデルにより、高いスケーラビリティと低レイテンシを実現
  • コンポーザビリティ(composability):小さく再利用可能なパーツを組み合わせることで、メンテナンス性、可読性、柔軟性の高いソフトウェアを構築する
  • リソースの安全な管理(resource-safety):処理が失敗したとしても、安全にリソースを開放する
  • 型安全性(type-safety):TypeScript の型システムを活用した型推論と型安全性に焦点を当てている
  • エラー処理(error handling):構造化された信頼性の高い方法でエラーを処理する
  • 非同期性(asynchronicity):非同期処理を同期処理と同じように書ける
  • オブザーバビリティ(observability):トレース機能により、簡単にデバッグや監視ができる

Effect-TS を用いると、割り算を行う関数は以下のように記述できます。

import { Effect } from "effect";
 
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b);
 
Effect.runSync(divide(10, 2).pipe(Effect.map((result) => console.log(result)))); // 5

Effect-TS を試してみる

実際に Effect-TS を使用して、より安全に API 通信を行う例を見てみましょう。Effect-TS を使用するには以下の要件が必要です。

  • TypeScript 5.4 以上
  • compilerOptionsstrict フラグを有効にする
tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

以下のコマンドで Effect-TS をインストールします。

npm install effect

素朴な API 通信の例

まずは Effect-TS を使わずに fetch API を使って API 通信を行う例を見てみましょう。

type User = {
  id: number;
  name: string;
};
 
const getUser = async (id: number): Promise<User> => {
  const data = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
 
  if (!data.ok) {
    throw new Error("Failed to fetch user");
  }
 
  const user = (await data.json()) as User;
 
  return user;
};
 
getUser(1)
  .then((user) => {
    console.log(user);
  })
  .catch((error) => {
    console.error(error);
  });

このコードは、fetch API を使って JSONPlaceholder からユーザー情報を取得しています。しかし、このコードにはいくつかの問題があります。

  • 型に守られていないエラー処理
  • as による型キャストを行っており、スキーマが変更された場合にランタイムエラーが発生する可能性がある

エラー処理について詳しく見てみましょう。fetch API はステータスコードが 200 番台以外の場合に ok プロパティが false になります。このため ok プロパティの値を見て、通信処理が失敗したときには throw new Error でエラーを投げています。例外を投げることによるエラー処理は TypeScript(JavaScript)の標準の方法として備わっていますし、スタックトレースにも記録されるため異常系を表現するためによく使われているでしょう。

しかし、例外を投げる方法はいくつかの理由で安全とは言い難いです。1 つ目の理由として、try/catch もしくは .catch メソッドで例外を捕捉することを忘れると、エラーがスルーされてしまい、アプリケーションがクラッシュする可能性がある点があげられます。Java のように、どの関数が例外を投げるか型システムで保証されているわけではないため、catch を書くことをコード上で強制できません。

2 つ目の理由は catch で捉えた例外が常に unknown 型であるため、型安全性が保証されない点です。これは TypeScript ではどのような型であっても throw できてしまうことが原因です。コード上で throw されるものが Error オブジェクトのインスタンスであることは保証できないのです。

このような例外を投げることによるエラーハンドリングの問題を解決するために、いくつかの手法が提案されていました。その中でも特に有名だと思われるのが、Result 型を使ったアプローチです。Result 型は成功を表す型と失敗を表す型のユニオン型となっています。以下は簡易的なコード例です。

type Success<T> = { type: "success"; value: T };
type Failure<E> = { type: "failure"; error: E };
type Result<T, E> = Success<T> | Failure<E>;

Result 型の利点はエラー処理を忘れることがない点です。型ガードにより成功時の処理に絞り込まない限り値を取り出せないため、条件分岐を書くことが強制されます。また Failure 型についても例外を投げていた場合と異なり、型安全性が保証されます。

const getUser = async (id: number): Promise<Result<User, Error>> => {
  const data = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
 
  if (!data.ok) {
    return { type: "failure", error: new Error("Failed to fetch user") };
  }
 
  const user = (await data.json()) as User;
 
  return { type: "success", value: user };
};
 
getUser(1).then((result) => {
  // この時点での result の型は Result<User, Error> となっているので、.value で値を取り出すことができない
  result;
  if (result.type === "success") {
    console.log(result.value);
  } else {
    console.error(result.error);
  }
});

ただし、Result 型を使ったアプローチも完全とは言えません。Scala や Rust のように言語仕様として Result 型が導入されているわけではないため、その他のライブラリや言語の標準機能との組み合わせが難しい点があります。いくら自身のコードは Result 型を使って丁寧なエラーハンドリングをしていても、その他のライブラリが例外を投げてくると破綻してしまいます。結局は外部のコードとの組み合わせの関係で try/catch.catch を使わざるを得ない場面が出てくるため、完全なエラーハンドリングを実現することは難しいのです。

また matchfold といった機能も TypeScript の Result 型には存在しないため、Scala や Rust のようにパターンマッチングを使ったエラーハンドリングを実現することはできず、どうしてもコードが冗長になってしまいます。

Effect-TS を使った API 通信の例

ここまでは TypeScript のみを使ったアプローチによる問題点や解決策について見てきました。続いて Effect-TS を導入したアプローチを見ていきます。Effect-TS で HTTP リクエストを行う処理は @effect/platform というパッケージに実装が分けられています。また、Node.js 環境で実行する場合 @effect/platform-node パッケージもインストールします。

npm install @effect/platform @effect/platform-node

コード例は以下のようになります。

import { NodeRuntime } from "@effect/platform-node";
import { HttpClientError } from "@effect/platform/Http/ClientError";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";
 
type User = {
  id: number;
  name: string;
};
 
const getUser = (id: number): Effect.Effect<unknown, HttpClientError> => {
  return Http.request
    .get(`https://jsonplaceholder.typicode.com/users/${id}`)
    .pipe(Http.client.fetchOK, Http.response.json);
};
 
NodeRuntime.runMain(
  getUser(1).pipe(Effect.andThen((user) => Console.log(user))),
);

Effect-TS では Effect という型を使って処理を表現します。Effect 型は引数の 1 つ目に成功を表す型(ここでは一旦 User 型ではなく unknown 型を使っています)を、2 つ目に失敗を表す型(HttpClientError)を指定します。さらに 3 つ目の型引数としてコンテキストデータを指定できます。

成功と失敗を表す型を併せ持つ点は、先に述べた Result 型とよく似ています。Effect-TS はこの Effect 型を使って同期処理・非同期処理・並行処理・リソース管理をモデル化します。Effect 型はイミュータブル(不変)であり、すべての関数は新しい Effect を作成します。

成功した場合の処理は Effect.success() で、失敗した場合の処理は Effect.failure() で表現できます。

import { Effect } from "effect";
 
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
  b === 0
    ? Effect.fail(new Error("Cannot divide by zero"))
    : Effect.succeed(a / b);

ここで API リクエストのために使用している Http.request.get メソッドは Effect 型を返す関数です。

const getUser = (id: number): Effect.Effect<unknown, HttpClientError> => {
  return Http.request
    .get(`https://jsonplaceholder.typicode.com/users/${id}`)
    .pipe(Http.client.fetchOK, Http.response.json);
};

Http.request.get() 関数の結果は .pipe() メソッドに渡されています。.pipe() メソッドを使用することにより、モジュール化され逐次的な方法でデータの操作と変換を行うことができます。.pipe() メソッドは、Effect 型の値を受け取り、新しい Effect 型の値を返す関数です。好きな数だけ処理をチェーンできます。これはパイプライン処理として知られています。

このパイプラインではステータスコードによりデータのエラーハンドリング処理と、JSON へのパース処理が行われています。

Http.request.get() メソッドは fetch API と同じく、 2xx 以下のステータスコードが返ってきた場合でもデフォルトではエラーとして扱いません。.pipe() メソッドに Http.client.fetchOk を渡すことにより、ステータスコードが 200 番台の場合のみ次の処理に進み、それ以外の場合には HTTPClientError が返し処理を中断するようになります。

Http.response.json メソッドは、fetch API の response.json() メソッドと同じように JSON データをパースします。パイプラインとして構築されているため、Http.client.fetchOk の処理で成功した場合のみこの処理が実行されます。

パイプライン処理は拡張が容易であり、新しい処理を追加したり拡張するのも簡単です。例として、レスポンスのスキーマを検証する処理を追加してみましょう。@effect/schema パッケージをインストールします。

npm install @effect/schema

@effect/schema@tsconfig.jsonexactOptionalPropertyTypes フラグを有効にするとより適した型を扱えます。

tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true
  }
}

続いて以下のように User スキーマを定義します。

import { Schema } from "@effect/schema";
 
const UserSchema = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
});
 
type User = Schema.Type<typeof UserSchema>;

そして、Http.response.json の代わりに Http.response.schemaBodyJson メソッドを使ってスキーマ検証を行います。このメソッドの引数には先程定義した UserSchema を渡します。レスポンスの JSON の構造が UserSchema と一致しない場合、ParseError が返され処理が中断されます。

const getUser = (
  id: number,
): Effect.Effect<User, HttpClientError | ParseError> => {
  return Http.request
    .get(`https://jsonplaceholder.typicode.com/users/${id}`)
    .pipe(
      Http.client.fetchOk,
      Effect.andThen(Http.response.schemaBodyJson(UserSchema)),
      Effect.scoped,
    );
};

スキーマの検証を行ったことにより、正常系では User 型の値が返されることが保証されます。これにより、getUser 関数の戻り値の型で unknown から User に変更できます。

Effect を実行する

作成した Effect を実行するためにはランタイムが必要です。Runtime<R> 型は Effect を実行できるランタイムを表します。Effect を実行するためにいくつかのユティリティ関数が提供されています。これらの関数はデフォルトのランタイムを使用します。多くの場合にはデフォルトのランタイムを使用すれば十分です。

  • Effect.runSync
  • Effect.runSyncExit
  • Effect.runPromise
  • Effect.runPromiseExit
  • Effect.runFork

Effect.runSync Effect<A, E> を受け取り A を返す関数です。同期的に Effect を実行するため、即座に値が返却されます。

import { Effect, Runtime } from "effect";
 
const program = Effect.log("Application started!");
 
Effect.runSync(program);
// timestamp=2024-04-29T09:36:34.919Z level=INFO fiber=#0 message="Application started!"
 
// 下記と同等
Runtime.runSync(Runtime.default)(program);

Effect-TS では platform-browser, platform-node, platform-bun のように、それぞれの実行環境に応じたランタイムシステムが提供されています。ここでは Node.js 環境で実行しているため、@effect/platform-node パッケージを使用します。

NodeRuntime.runMain(
  getUser(1).pipe(Effect.andThen((user) => Console.log(user))),
);

NodeRuntime.runMain 関数は、Effect を実行するためのエントリーポイントです。getUser(1) の結果の Effect.pipe メソッドで Console.log に渡しています(Console.log もまた Effect を返す関数です)。

このアプリケーションを実行すると、ユーザー情報がコンソールに表示されます。

{ id: 1, name: 'Leanne Graham' }

また、URL を存在しないものに変更してみると、HttpClientError が発生し、エラーメッセージが表示されます。

timestamp=2024-04-29T09:47:44.978Z level=ERROR fiber=#0 cause="ResponseError: StatusCode error (404 GET https://jsonplaceholder.typicode.com/unknown): non 2xx status code

より優れたエラーハンドリング

現状のコードでも概ね問題なく動作しますが、Effect.andThen の引数の型が unknown となっているという問題があります。またエラーは自動でキャッチされているものの、処理が不明瞭であるため好ましくないでしょう。ここでは Effect.gen() 関数を使用して、ジェネレーター を使ったより効果的な処理を記述します。

まずは基本的な Effect.gen() の使い方を見てみましょう。

const program = Effect.gen(function* () {
  const user = yield* getUser(1);
  yield* Console.log(user);
});
 
NodeRuntime.runMain(program);

Effect.gen() にはジェネレーター関数を渡します。ジェネレーター関数内では yield* を使って Effect を実行します。ジェネレーター関数内で Effect を実行することで、非同期処理を逐次的に記述することができます。

ジェネレーター関数を使用したコードは async/await によるフローとよく似ています。yield*await に相当し、getUser(1) 関数の実行が完了するまで待機します。続いて Console.log(user) が実行されます。

yield* getUser(1) の結果は User 型となっています。getUser() 関数でエラーが返された場合にはそこで実行が中断されるため、エラーが発生した場合の型が存在しないのです。

エラーをキャッチして処理を行う場合には、Effect.Either を使います。EitherLeftRight の 2 つの値を持つデータ型です。習慣として、Left はエラーを表し、Right は成功を表します。

Either.isLeft 関数を使うことで、Either 型の値が Left かどうかを判定できます。Effect.isLeft で絞り込まれたブロックは失敗した場合の処理となります。

import { Console, Effect, Either } from "effect";
 
const program = Effect.gen(function* () {
  const failureOrSuccess = yield* Effect.either(getUser(1));
 
  if (Either.isLeft(failureOrSuccess)) {
    yield* Console.error(failureOrSuccess.left);
  } else {
    yield* Console.log(failureOrSuccess.right);
  }
});

エラーの場合には HTTPClientError だけでなく、ParseError などのエラーも発生する可能性があるため、それぞれで分岐した処理を書きたいことでしょう。その場合には、_tag プロパティを使ってエラーの種類を判定します。

import { Console, Effect, Either } from "effect";
 
const program = Effect.gen(function* () {
  const failureOrSuccess = yield* Effect.either(getUser(1));
 
  if (Either.isLeft(failureOrSuccess)) {
    const error = failureOrSuccess.left;
 
    if (error._tag === "RequestError" || error._tag === "ResponseError") {
      yield* Console.error(`Http Error: ${error.error}`);
    } else if (error._tag === "ParseError") {
      yield* Console.error(`Parse Error: ${error.error}`);
    }
  } else {
    yield* Console.log(failureOrSuccess.right);
  }
});

Either.match 関数を使用するとより簡潔なコードを書くことができます。この関数は成功した場合と失敗した場合のコールバック関数を受け取ります。

import { Console, Effect, Either } from "effect";
 
const program = Effect.gen(function* () {
  const failureOrSuccess = yield* Effect.either(getUser(1));
 
  yield* Either.match(failureOrSuccess, {
    onLeft: function* (error) {
      if (error._tag === "RequestError" || error._tag === "ResponseError") {
        yield* Console.error(`Http Error: ${error.error}`);
      } else if (error._tag === "ParseError") {
        yield* Console.error(`Parse Error: ${error.error}`);
      }
    },
    onRight: function* (user) {
      yield* Console.log(user);
    },
  });
});

この記事で紹介した Effect-TS 機能のほんの一部にすぎません。興味を持った方はぜひ公式ドキュメントを参照してください。

まとめ

  • Effect-TS は、TypeScript の型システムを活用してエラーや非同期処理をより安全に扱うためのライブラリ
  • Effect-TS は Effect System という概念を取り入れており、Scala や Haskell に影響を受けている
  • TypeScript の言語仕様として備わっている throw によるエラーハンドリングは安全とは言い難い
  • Effect-TS は Effect 型を使ってエラーや非同期処理をモデル化する。成功を表す型、失敗を表す型、コンテキストデータを型引数として指定する
  • Effect 関数の結果は .pipe() メソッドに渡され、モジュール化され逐次的な方法でデータの操作と変換を行うことができる
  • Effect を実行するためにはランタイムが必要で、Effect.runSync Effect.runPromise などの関数が提供されている
  • Effect.gen() 関数を使うことでジェネレーターを使った処理を記述できる

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

Effect.Either において成功を表す型は次のうちどちらか?

  • Left

    もう一度考えてみましょう

  • Right

    正解!

    英語では「正しい」と「右」が同じ単語ですから、成功を表す型は Right と覚えましょう。


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事