abstraction-patterns
リポジトリ: Effect-TS/effect 分析日: 2026-02-18
概要
Effect-TS のコアライブラリは、Pipeable / Inspectable / Equal / Hash という小さなプロトコルインターフェースを定義し、それらをプロトタイプオブジェクトの合成で全データ型に横断適用している。TypeScript の言語制約(HKT 非対応、ランタイム mixin 不在)を Symbol ベースの型タグと dual() による data-first/data-last 両用 API で乗り越え、177 以上のモジュールに一貫した操作体系を提供している点が注目に値する。
背景にある原則
Protocol-over-Inheritance(プロトコルが継承に優先する): 共通の振る舞いを基底クラスの継承ではなく、Symbol キー付きプロトコルインターフェース(
Equal.symbol,Hash.symbol等)で定義すべき。なぜなら、TypeScript ではクラス多重継承が不可能であり、プロトコルなら任意の型が複数を同時に実装できるため。Equal.ts:13,Hash.ts:19でそれぞれSymbol.for("effect/Equal"),Symbol.for("effect/Hash")をプロトコル識別子として定義している。Prototype Composition(プロトタイプの合成による横断的関心事の付与): 共通プロトコルの実装を個々のクラスに重複して書くのではなく、プロトタイプオブジェクトを
Object.assign/Object.createで層状に合成して一箇所に集約すべき。なぜなら、プロトコル実装の変更が全データ型に自動伝播し、保守コストが激減するため。internal/effectable.ts:68-85のEffectPrototypeにEqual,Hash,pipe,Symbol.iteratorが集約され、internal/option.ts:14-25のCommonProtoが...EffectPrototypeで展開される。Phantom Type による型安全な変性エンコード: ランタイムコストなしでジェネリクスの共変性・反変性を型レベルで表現すべき。
Types.ts:301のCovariant<A> = (_: never) => AとTypes.ts:321のContravariant<A> = (_: A) => voidを型パラメータに埋め込み、TypeScript の関数型の変性規則を利用して安全な代入を実現する。Symbol.for による跨境識別: TypeId を
Symbol.for("effect/...")で定義すべき。ローカル Symbol ではなくSymbol.forを使うことで、CJS/ESM 混在環境やホットリロード環境でも同一のプロトコル識別が保証される。GlobalValue.ts:42-53も同様の原則に基づき、グローバルシングルトンの再生成を防止する。
実例と分析
プロトコルの階層構造
Effect-TS のプロトコルは以下の依存関係で層をなしている:
- Hash — 値のハッシュ値を返す
[Hash.symbol](): number - Equal (extends Hash) — 値の等価性を判定する
[Equal.symbol](that: Equal): boolean - Pipeable — 関数合成チェーンを可能にする
pipe()メソッド - Inspectable —
toString(),toJSON(),[NodeInspectSymbol]()のデバッグ表現 - Effectable — Effect ワークフローとして扱える
[EffectTypeId],[Symbol.iterator]()
これらが EffectPrototype に集約され、Option, Either, Chunk, List, Context.Tag など全データ型の基盤となる。
プロトタイプ合成の実装パターン
Option の実装に見る 3 層構造:
// packages/effect/src/internal/effectable.ts:68-85
export const EffectPrototype: Effect.Effect<never> & Equal.Equal = {
[EffectTypeId]: effectVariance,
[StreamTypeId]: effectVariance,
[SinkTypeId]: sinkVariance,
[ChannelTypeId]: channelVariance,
[Equal.symbol](that: any) {
return this === that;
},
[Hash.symbol]() {
return Hash.cached(this, Hash.random(this));
},
[Symbol.iterator]() {
return new SingleShotGen(new YieldWrap(this)) as any;
},
pipe() {
return pipeArguments(this, arguments);
},
};// packages/effect/src/internal/option.ts:14-25
const CommonProto = {
...EffectPrototype, // 第1層: pipe, Equal, Hash, iterator
[TypeId]: { _A: (_: never) => _ }, // 第2層: Option 固有の TypeId
[NodeInspectSymbol]() {
return this.toJSON();
},
toString() {
return format(this.toJSON());
},
};
// packages/effect/src/internal/option.ts:27-43
const SomeProto = Object.assign(Object.create(CommonProto), {
_tag: "Some", // 第3層: バリアント固有
[Equal.symbol](that) { // Equal のオーバーライド
return isOption(that) && isSome(that) && Equal.equals(this.value, that.value);
},
[Hash.symbol]() {
return Hash.cached(this, Hash.combine(Hash.hash(this._tag))(Hash.hash(this.value)));
},
toJSON() {
return { _id: "Option", _tag: this._tag, value: toJSON(this.value) };
},
});同一パターンが Either(internal/either.ts:20-67)、Chunk(Chunk.ts:125-163)、List(List.ts:100-152)、MutableRef(MutableRef.ts:27-44)など全データ型で一貫して使われている。
dual() による data-first / data-last 両用 API
// packages/effect/src/Function.ts:95-128
export const dual: {
<DataLast extends (...args: Array<any>) => any, DataFirst extends (...args: Array<any>) => any>(
arity: Parameters<DataFirst>["length"],
body: DataFirst,
): DataLast & DataFirst;
} = function(arity, body) {
switch (arity) {
case 2:
return function(a, b) {
if (arguments.length >= 2) {
return body(a, b); // data-first: fn(self, arg)
}
return function(self: any) {
return body(self, a); // data-last: self.pipe(fn(arg))
};
};
// case 3, 4, 5 も同様...
}
};コードベース全体で 254 箇所以上で使用されている(Chunk.ts 43 箇所、Effect.ts 3 箇所以上、Readable.ts 2 箇所など)。引数の数で data-first/data-last を自動判定するため、同一関数を pipe チェーンでもスタンドアロンでも使える。
TypeId + hasProperty による型ガード
// packages/effect/src/internal/option.ts:64
export const isOption = (input: unknown): input is Option.Option<unknown> => hasProperty(input, TypeId);
// packages/effect/src/Readable.ts:36
export const isReadable = (u: unknown): u is Readable<unknown, unknown, unknown> => hasProperty(u, TypeId);instanceof ではなく Symbol.for ベースの TypeId の存在チェックで型判定する。これにより CJS/ESM の境界やバージョン違いでも正しく型判定できる。
declare module による型の後付け拡張
// packages/effect/src/Effect.ts:168-213
declare module "./Context.js" {
interface Tag<Id, Value> extends Effect<Value, never, Id> {}
}
declare module "./Either.js" {
interface Left<E, A> extends Effect<A, E> {}
interface Right<E, A> extends Effect<A, E> {}
}
declare module "./Option.js" {
interface None<A> extends Effect<A, Cause.NoSuchElementException> {}
interface Some<A> extends Effect<A, Cause.NoSuchElementException> {}
}Option, Either, Tag が Effect を extends する宣言を、Effect.ts 側から declare module で後付けする。ランタイムでは EffectPrototype のスプレッドで既にプロトコルが付与されているため、型レベルのみの拡張。循環依存を型レベルで解決する手法。
パターンカタログ
Protocol パターン (分類: 構造)
- 解決する問題: 多重継承できない言語で複数の横断的振る舞いを型に付与する
- 適用条件: 複数の独立した振る舞い(等価性、ハッシュ、パイプ等)を多数の型で共有する場合
- コード例:
Equal.ts:13,Hash.ts:19,Pipeable.ts:11 - 注意点: Symbol キーの衝突を避けるため
Symbol.for("namespace/Name")の命名規則が必要
Prototype Chain Composition (分類: 生成)
- 解決する問題: プロトコル実装のボイラープレートを排除し、変更の一括伝播を実現する
- 適用条件: 共通プロトコルを持つデータ型が多数存在する場合
- コード例:
internal/effectable.ts:68-131,internal/option.ts:14-61 - 注意点:
Object.createでプロトタイプチェーンが深くなりすぎるとプロパティルックアップが遅くなる
Dual API パターン (分類: 振る舞い / Adapter の変形)
- 解決する問題: パイプスタイル(data-last)と直接呼び出し(data-first)の両方をサポートする
- 適用条件: 関数型スタイル(pipe)とメソッドチェーンスタイルの両方を提供したい API
- コード例:
Function.ts:95-128,Readable.ts:60-65 - 注意点: arity が 0 または 1 の関数には適用できない(
Function.ts:117-118)
Good Patterns
- Symbol.for による跨境 TypeId:
Symbol.for("effect/Option")のようにグローバルシンボルレジストリを使い、CJS/ESM 混在・ホットリロード環境でも型識別が壊れない。
// packages/effect/src/internal/option.ts:12
const TypeId: Option.TypeId = Symbol.for("effect/Option") as Option.TypeId;
// packages/effect/src/internal/option.ts:64
export const isOption = (input: unknown): input is Option.Option<unknown> => hasProperty(input, TypeId);- Hash のキャッシュ:
Hash.cached()でハッシュ値をObject.definePropertyで不変プロパティとしてオブジェクトに焼き込み、再計算を防ぐ。enumerable: falseにすることで JSON シリアライズに影響しない。
// packages/effect/src/Hash.ts:169-195
export const cached = function() {
const self = arguments[0] as object;
const hash = arguments[1] as number;
Object.defineProperty(self, symbol, {
value() {
return hash;
},
enumerable: false,
});
return hash;
};- Inspectable の toJSON → toString → NodeInspect の統一チェーン:
toJSON()を唯一の真のソースとし、toString()はformat(this.toJSON())を、[NodeInspectSymbol]()はthis.toJSON()を返す。デバッグ表現が全データ型で一貫する。
// packages/effect/src/Inspectable.ts:170-180
export const BaseProto: Inspectable = {
toJSON() {
return toJSON(this);
},
[NodeInspectSymbol]() {
return this.toJSON();
},
toString() {
return format(this.toJSON());
},
};- Variance の Phantom Type エンコード: ランタイムの関数
(_: never) => _をダミー値として型パラメータに埋め込み、TypeScript の型推論で共変性を正しく扱わせる。
// packages/effect/src/internal/effectable.ts:26-35
export const effectVariance = {
_R: (_: never) => _,
_E: (_: never) => _,
_A: (_: never) => _,
_V: version.getCurrentVersion(),
};Anti-Patterns / 注意点
- プロトコル未実装の生オブジェクト混在:
Data.struct()を使わずプレーンオブジェクトを作るとEqual.equalsが===にフォールバックし、構造的等価性が失われる。
// Bad: プロトコルなしの生オブジェクト
const a = { name: "Alice", age: 30 };
const b = { name: "Alice", age: 30 };
Equal.equals(a, b); // false(参照比較になる)
// Better: Data.struct でプロトコルを付与
const a = Data.struct({ name: "Alice", age: 30 });
const b = Data.struct({ name: "Alice", age: 30 });
Equal.equals(a, b); // true(構造的等価性)- instanceof による型判定: バンドラーの tree-shaking やモジュール重複で
instanceofが壊れるため、hasProperty(u, TypeId)パターンを使うべき。
// Bad: instanceof は CJS/ESM 混在で壊れうる
if (value instanceof Option) { ... }
// Better: Symbol ベースの TypeId チェック
if (hasProperty(value, TypeId)) { ... }- プロトタイプの直接変更:
Object.create(Proto)で生成したオブジェクトに対して後からObject.setPrototypeOfでチェーンを差し替えると、V8 のインラインキャッシュが無効化されパフォーマンスが劣化する。Effect-TS ではObject.create時点でプロトタイプを確定し、後から変更しない。
導出ルール
[MUST]複数の型が共有する横断的振る舞いは、基底クラスの継承ではなく Symbol キー付きプロトコルインターフェースで定義する- 根拠: Effect-TS では Equal/Hash/Pipeable/Inspectable の 4 プロトコルが 177 以上のモジュールで使われており、多重継承の制約を完全に回避している(
Equal.ts:13,Hash.ts:19)
- 根拠: Effect-TS では Equal/Hash/Pipeable/Inspectable の 4 プロトコルが 177 以上のモジュールで使われており、多重継承の制約を完全に回避している(
[MUST]型の識別にはinstanceofではなくSymbol.for()ベースの TypeId +hasProperty型ガードを使う- 根拠: CJS/ESM 混在・ホットリロード・バンドラー重複で
instanceofが壊れるのに対し、Symbol.forはプロセス内でグローバルに一意(internal/option.ts:12,64)
- 根拠: CJS/ESM 混在・ホットリロード・バンドラー重複で
[SHOULD]プロトコル実装をプロトタイプオブジェクトに集約し、Object.create+Object.assignで層状に合成する- 根拠: 実装変更が全データ型に自動伝播する。Option/Either/Chunk/List/MutableRef の全てが
EffectPrototypeを起点に合成されている(internal/effectable.ts:68-85)
- 根拠: 実装変更が全データ型に自動伝播する。Option/Either/Chunk/List/MutableRef の全てが
[SHOULD]data-first と data-last の両方をサポートする API はdual()パターンで実装し、引数の arity で自動判定する- 根拠: コードベース全体で 254 箇所以上使われ、
pipeチェーンとスタンドアロン呼び出しの両方を単一実装で提供している(Function.ts:95-128)
- 根拠: コードベース全体で 254 箇所以上使われ、
[SHOULD]デバッグ表現はtoJSON()を唯一のソースとし、toString()と Node.js inspect をそこから導出する- 根拠: 全データ型が
Inspectable.BaseProtoの 3 メソッドチェーン(toJSON → toString → NodeInspect)で統一され、デバッグ体験が一貫する(Inspectable.ts:170-180)
- 根拠: 全データ型が
[SHOULD]不変データのハッシュ値は初回計算後にObject.defineProperty(enumerable: false)でキャッシュする- 根拠:
Hash.cachedがこのパターンで実装され、HashMap/HashSet での繰り返しルックアップのコストを排除している(Hash.ts:169-195)
- 根拠:
[AVOID]プロトコルを実装していないプレーンオブジェクトとプロトコル付きオブジェクトを混在させる- 根拠:
Equal.equalsがプロトコル非実装オブジェクトに対して参照比較にフォールバックし、意図しない不等価判定が起きる(Equal.ts:36-86)
- 根拠:
適用チェックリスト
- [ ] プロジェクトのデータ型が共有する横断的振る舞い(等価性、シリアライズ、パイプ等)を洗い出し、Symbol キー付きプロトコルとして定義したか
- [ ] 型識別に
instanceofではなくSymbol.forベースの TypeId + 型ガード関数を使っているか - [ ] 共通プロトコルの実装を Proto オブジェクトに集約し、各データ型が
Object.createで継承しているか - [ ] API 関数が pipe チェーンとスタンドアロン呼び出しの両方で使えるか(dual パターンの適用検討)
- [ ]
toJSON()を唯一のデバッグ表現ソースとし、toString()と inspect がそこから導出されているか - [ ] 頻繁にアクセスされるハッシュ値や計算結果を
Object.defineProperty(enumerable: false)でキャッシュしているか - [ ] CJS/ESM 混在環境やホットリロード環境でシングルトンやグローバル状態が破壊されないか確認したか