🔖

Effect-TSでサーバーとクライアントの境界を安全に跨ぐには

に公開

はじめに

昨今、フルスタックフレームワークが浸透し、バックエンドとフロントエンドで型やコードを共有することは、モダンな技術を追い求める開発者にとって受け入れられつつあります。しかし、我々の前に立ちはだかるのはサーバーとクライアントの境界です。サーバー側の処理をまるで関数のように呼び出せたとしても、ネットワークを超える必要があるというウェブの基本的な構造は変わりません。送受信されるデータはシリアライズ可能でなければならず、開発者たちはSuperJSONなどのライブラリを用いてこの制約に対処してきました。

本記事では、Effectを用いてこの境界を乗り越える方法を探ります。

Effectとは

本題に入る前に、Effectについて簡単に説明しておきます。

Effectは、TypeScriptで関数型エフェクトシステムを提供するフレームワークであり、型安全にエラーや副作用を扱うことができます。EffectのコアとなるEffect.Effect<Success, Error, Requirements>は、以下の3つの要素を表現します。

         ┌─── 成功時の値の型
         │        ┌─── 失敗時のエラーの型
         │        │      ┌─── 必要な依存関係
         ▼        ▼      ▼
Effect.Effect<Success, Error, Requirements>
  • Success: 成功時に返される値の型
  • Error: 失敗時に返されるエラーの型(予期されたエラー)

ここまではいわゆるResult型と似ていますが、Effectの特徴的な部分は以下の点です。

  • Requirements: 実行に必要な依存関係の型

これにより、Effectは成功時の値だけでなく、失敗や依存を型システムを通じて明示し、プログラムをより予測可能にします。

Effectでは、エラーを次の2種類に分類します。

  • 予期されたエラー: 開発者が通常のプログラム実行の一部として予期するエラー。型パラメータEで表現され、型安全に扱えます。
  • 予期しないエラー: システムエラーやバグ。型パラメータには含まれません。defectとも呼ばれます。

この区別により、どのエラーを扱う必要があるかが型レベルで明確になります。

実装例 - ユーザー登録フォーム

次のようなユーザー登録フォームを題材に、Effectでサーバー/クライアント境界をどう扱うかを見ていきます。

空のフォーム

要件は以下の通りです。

  • ユーザー名、ニックネームを入力
  • ユーザー名は重複不可
  • エラーは適切なフィールドに表示

モデルを定義する

まずは扱うデータをSchemaとして定義します。EffectのSchemaは実行時のバリデーションとシリアライゼーション/デシリアライゼーションを提供します。

import { Schema } from "effect"

const UserName = Schema.String.pipe(
	Schema.trimmed(),
	Schema.minLength(4),
	Schema.maxLength(16),
	Schema.pattern(/^[a-zA-Z0-9_]+$/),
	Schema.brand("UserName"),
);
type UserName = typeof UserName.Type;

const UserNickname = Schema.String.pipe(
	Schema.trimmed(),
	Schema.minLength(1),
	Schema.maxLength(32),
	Schema.brand("UserNickname"),
);

const User = Schema.Struct({
	name: UserName,
	nickname: UserNickname,
});
type User = typeof User.Type;
ブランド型について

ブランド型は、基本的な型に追加の意味付けを行うためのテクニックです。EffectのSchemaではSchema.brandを用いてブランド型を定義できます。これにより、例えばUserNameが単なるstringと混ざらないようにできます。

エラーを定義する

次に、発生しうるエラーを定義します。タグ付きエラーを用いることで、Effect.catchTagなどのEffect標準の機能と連携できます。

import { Data } from "effect"

class UserNameDuplicationError extends Data.TaggedError(
	"UserNameDuplicationError",
)<{
	name: UserName;
}> {}

サーバーサイドの処理を実装する

次に、サーバーサイドでユーザー登録のロジックを実装します。ユーザー名の重複チェックとユーザー登録を行います。

import { Effect } from "effect"

const CreateUserInput = Schema.Struct({
	name: UserName,
	nickname: UserNickname,
});
type CreateUserInput = typeof CreateUserInput.Type;

function createUser(
	input: CreateUserInput,
): Effect.Effect<
	User,
	UserNameDuplicationError,
	UserRepository | UserNameDuplicationChecker
> {
	return Effect.gen(function* () {
		const userRepository = yield* UserRepository;
		const checkUserNameDuplication = yield* UserNameDuplicationChecker;

		yield* checkUserNameDuplication(input.name);

		const user = Schema.decodeSync(User)(input);

		return yield* userRepository.save(user);
	});
}
UserRepositoryやUserNameDuplicationCheckerについて

Effectの依存はContextServiceを用いて実装できます。ここでは詳細を割愛しますので、公式ドキュメントのManaging Servicesなどを参照してください。

class UserRepository extends Effect.Service<UserRepository>()(
	"UserRepository",
	{
		accessors: true,
		sync: () => {
			const existsUsers: User[] = [
				Schema.decodeSync(User)({
					name: "yu7400ki",
					nickname: "Yuki",
				}),
			];

			return {
				findByName: (name: UserName) =>
					Effect.sync(
						() => existsUsers.find((user) => user.name === name) ?? null,
					),
				save: (user: User) => Effect.sync(() => user),
			};
		},
	},
) {}

class UserNameDuplicationChecker extends Effect.Service<UserNameDuplicationChecker>()(
	"UserNameDuplicationChecker",
	{
		accessors: true,
		effect: Effect.gen(function* () {
			const userRepository = yield* UserRepository;
			return (username: UserName) =>
				Effect.gen(function* () {
					const existingUser = yield* userRepository.findByName(username);
					if (existingUser) {
						yield* Effect.fail(
							new UserNameDuplicationError({ name: username }),
						);
					}
				});
		}),
		dependencies: [UserRepository.Default],
	},
) {}

クライアントからサーバーサイドの処理を呼び出す

サーバーサイドの処理を公開し、クライアントから呼び出せるようにします。ここではTanStack Startを用います。

import { createServerFn } from "@tanstack/react-start";

const createUserFn = createServerFn({ method: "POST" })
	.inputValidator(Schema.standardSchemaV1(CreateUserInput))
	.handler(({ data }) => {
		const layer = Layer.mergeAll(
			UserRepository.Default,
			UserNameDuplicationChecker.Default,
		);
		const program: Effect.Effect<User, UserNameDuplicationError, never> =
			createUser(data).pipe(Effect.provide(layer));
		return Effect.runPromise(program);
	});

Requirementsneverになっています。これは、必要な依存関係がすべて提供され、Effectが実行可能であることを示しています。

そしてクライアントサイドの呼び出しはきっとこのようになるでしょう。

try {
	await createUserFn({ data: value });
} catch (error) {
	// UserNameDuplicationError の場合の処理なら...
	// return {
	// 	fields: {
	// 		name: {
	// 			message: "このユーザー名は既に使用されています",
	// 		},
	// 	},
	// };
}

……せっかくEffectで型安全にサーバーサイドの処理を実装したにもかかわらず、エラーの型が失われてしまいました。runPromiseでエラーがthrowされるため、catchで捕捉したときには型情報が失われてしまうのです。
これを解決するためにはcreateUserFnの実装を工夫する必要がありそうです。

Effectの結果をシリアライズ可能にする

const program: Effect.Effect<User, UserNameDuplicationError, never> =
	createUser(data).pipe(Effect.provide(layer));

ここですべての依存は解決されました。もはやEffect.Effectは必要ありません。欲しいのは一般的なResult型です。EffectではExitEitherがこれに近いと思います。

  • Exit<A, E>: Effectの実行結果を表現する型で、成功と失敗だけでなく、予期しないエラー(defect)も含みます
  • Either<R, L>: 2つの値を表現する型で、成功や失敗の結果を表現するのに使えます(慣例的にLeftが失敗、Rightが成功を表します)

今回はExitを使いましょう。Schemaとして定義することで、シリアライズ可能にできます。

const CreateUserFnResult = Schema.Exit({
	success: User,
	failure: UserNameDuplicationError,
	defect: Schema.Null,
});

defectnullとしています。サーバーサイドの予期しないエラーをクライアントに露出させないためです。

UserNameDuplicationErrorData.TaggedErrorなので今のままではシリアライズできません。Schema.TaggedErrorを使って定義し直します。

- class UserNameDuplicationError extends Data.TaggedError(
- 	"UserNameDuplicationError",
- )<{
- 	name: UserName;
- }> {}
+ class UserNameDuplicationError extends Schema.TaggedError<UserNameDuplicationError>(
+ 	"UserNameDuplicationError",
+ )("UserNameDuplicationError", {
+ 	name: UserName,
+ }) {}

これでcreateUserFnの実装を以下のように変更できます。

const createUserFn = createServerFn({ method: "POST" })
	.inputValidator(Schema.standardSchemaV1(CreateUserInput))
	.handler(({ data }) => {
		const layer = Layer.mergeAll(
			UserRepository.Default,
			UserNameDuplicationChecker.Default,
		);
- 		const program = createUser(data).pipe(Effect.provide(layer));
+		const program = createUser(data).pipe(
+			Effect.provide(layer),
+			Effect.catchAllDefect(() => Effect.die(null)),
+			Effect.exit,
+			Effect.map(Schema.encodeSync(CreateUserFnResult)),
+		);
		return Effect.runPromise(program);
	});

これで返り値はPromise<Exit<User, UserNameDuplicationError>>となり、シリアライズ可能です。

クライアントでExitを元に戻す

クライアントサイドでは次のようにデシリアライズすることで、再びEffectの世界へ戻ることができます。

const result = await createUserFn({ data: value });
const program = Schema.decodeSync(CreateUserFnResult)(result).pipe(
	Effect.map(() => undefined),
	Effect.catchTag("UserNameDuplicationError", () =>
		Effect.succeed({
			fields: {
				name: {
					message: "このユーザー名は既に使用されています",
				},
			},
		}),
	),
);
const errors = await Effect.runPromise(program);
return errors;

フォームのエラー表示

おわりに

Effectを用いることで、サーバーとクライアントの境界を超えて型安全にエラーやデータを扱うことができました。Effectの強力な型システムとSchemaのシリアライゼーション機能を組み合わせることで、フルスタックで一貫した型安全性を実現できます。特にサーバーサイドでEffectを使用するプロジェクトであれば、クライアントサイドでも同じパラダイムを享受できるでしょう。

参考

おまけ

記事内で使用したフォームはPark UIというライブラリを使っています。ヘッドレスコンポーネントライブラリのArk UIとCSS-in-JSライブラリのPanda CSSを用いて構築されています。私の推しですので、ぜひ使ってみてください。

chot Inc. tech blog

Discussion