TypeScript yieldベストプラクティス!Generator型の戻り値を厳格に指定して型崩れを防ぐ開発術

TypeScriptのGenerator関数でyieldとnextの型を厳格に指定し、型崩れを防止する開発手法を解説した記事のアイキャッチ画像 プログラミング言語

TypeScript開発において、Generator関数は非同期フローや無限シーケンス、状態遷移の制御など、多様なユースケースを解決する強力な抽象化手段です。
しかし、その柔軟性ゆえに戻り値の型が曖昧になりがちで、特に大規模プロジェクトでは「yieldが何を返すのか」「next()に渡す値は何か」が不明瞭になり、予期しない型崩れや実行時エラーの温床となります。
この問題の本質は、Generatorが双方向の通信チャネルである点にあります。
yieldは値を送出するだけでなく、next()の引数を受け取る入口でもあるため、その入出力の型を厳密に定義しなければ、呼び出し側と実装側の契約が破綻します。

そこで本記事では、TypeScriptが提供するGenerator型とIterator型の体系を正しく理解し、戻り値の型を三段階(YieldType・ReturnType・NextType)で明示する実践的なパターンを解説します。
具体的には、以下のアプローチを採用します。

  • ジェネリック型 Generator<Y, R, N> を利用し、各型パラメータに具体的な型をバインドすることで、yield式の評価結果やreturn文の終了値を完全に静的に把握できるようにする
  • next() の引数に型安全性を担保するため、N には許容される入力値のユニオン型や条件型を設定し、不正な値の流入をコンパイル時に遮断する
  • 複雑なフローでは ts-morph や eslint-plugin による静的解析ではなく、型レベルで制御する設計を優先し、実行時アサーションを最小化する

例えば、以下のようなAPIクライアントのリトライ制御をGeneratorで実装するケースを考えます。

type RetryStep = { attempt: number; delay: number };
type RetryCommand = 'continue' | 'abort' | { customDelay: number };
type RetryResult = { succeeded: boolean; finalAttempt: number };

function* retryController(): Generator<
  RetryStep,          // yield が返す値(呼び出し側が受け取る)
  RetryResult,        // return の最終値
  RetryCommand        // next() に渡せる引数の型
> {
  for (let i = 1; i <= 3; i++) {
    const command = yield { attempt: i, delay: 1000 * i };
    if (command === 'abort') return { succeeded: false, finalAttempt: i };
    if (command === 'continue') continue;
    if (typeof command === 'object' && 'customDelay' in command) {
      yield { attempt: i, delay: command.customDelay };
    }
  }
  return { succeeded: true, finalAttempt: 3 };
}

この定義により、呼び出し側は next('continue') や next({ customDelay: 500 }) のように型安全に操作でき、誤って数値や真偽値を渡せば型エラーになります。
また、return の型も明確なので、非同期処理の最終状態をPromiseと組み合わせて扱う際にも不整合が起きません。

さらに、Generatorを利用するラッパー関数では、戻り値の型を Generator<Y, R, N> で明示するだけでなく、IteratorResult<Y, R> を利用した部分型チェックを導入することで、yieldとreturnの区別を型レベルで強制できます。
これにより、コードレビュー時の認識齟齬を減らし、リファクタリング時の影響範囲をコンパイラが正確に教えてくれるようになります。

型パラメータ 役割 指定しない場合のデフォルト 推奨する指定方法
Y (Yield) yield式が返す値の型 unknown 実際にyieldするオブジェクト型やリテラルユニオン
R (Return) return文の最終値の型 unknown 成功/失敗を表す判別ユニオン型
N (Next) next()引数の型 unknown 許可するコマンドのユニオン型(文字列リテラルやオブジェクト型)

この表のように、三つの型パラメータをすべて明示することで、Generatorは単なる反復子から「型付けされたプロトコル」へと昇華します。
特にNext型を限定することは、外部からの入力を制約する防御的設計の要であり、これにより想定外の制御フローを排除できます。

最後に、実務で留意すべき点として、型パラメータは省略せず、常に三つ組で記述する習慣を徹底してください。
Generator<number> のように一部だけ指定すると、残りはunknownとなり、結局は型崩れを許容します。
また、複雑なGeneratorは複数の小さなGeneratorに分割し、それぞれに厳格な型を与えることで、テスト容易性と保守性が向上します。
TypeScriptの型システムは、正しく使えば実行時エラーの大半を撲滅する強力な武器です。
yieldの両方向性を型で制御し、堅牢なコードベースを構築しましょう。

  1. なぜGeneratorの型指定が重要なのか? 型崩れが招く3つのトラブル
  2. TypeScriptが提供するGenerator型の基本構造 Y・R・Nの意味を正しく理解する
    1. Y(Yield型):呼び出し側が受け取る値の型
    2. R(Return型):Generatorの最終的な終了値の型
    3. N(Next型):呼び出し側が渡す入力値の型
    4. 三つの型パラメータの関係性と省略時のデフォルト動作
    5. 具体的な型パラメータの指定例とその効果
    6. 型パラメータ指定時の落とし穴と回避策
  3. yieldの戻り値を厳格に指定する実践パターン ユニオン型とリテラル型の活用法
    1. リテラル型で状態遷移を表現するパターン
    2. オブジェクト型とユニオン型を組み合わせて詳細情報を付与するパターン
    3. 判別可能なユニオン型(Discriminated Union)を用いた高度な設計
    4. ジェネリック型を導入して汎用的なyield型を再利用する
    5. 実践上の注意点と型の拡張に備えた設計
    6. 表:yield型の設計パターンと適したユースケース
  4. next()引数の型を制限して外部入力の危険を排除する防御的設計
    1. Nの本質とデフォルト動作がもたらす危険性
    2. 文字列リテラルユニオンでコマンドパターンを実装する
    3. オブジェクト型で複雑なパラメータを構造化して渡す
    4. ユニオン型と判別子を組み合わせた拡張性の高い設計
    5. 初回next()呼び出しにおける型制約の例外とその対処法
    6. 表:Nの型設計パターンと適用シーン
    7. 実務で気をつけるべきエッジケース
  5. return文の最終値まで型安全に扱う 終了状態を判別ユニオンで表現する
    1. R(Return型)が省略されることの代償
    2. 判別ユニオン型による終了状態の表現
    3. 非同期処理との連携におけるRの役割
    4. return文を複数持つ場合の型整合性
    5. 実行時エラーとreturn型の関係
    6. 表:Rの設計パターンと推奨ユースケース
    7. 実務上のベストプラクティス
  6. ジェネリックラッパー関数でGenerator型を派生させる高度な型操作テクニック
    1. ラッパー関数が解決する三つの課題
    2. 基本的なラッパーパターン:Yを変換する
    3. Rを拡張するラッパー:終了状態にログ情報を付与する
    4. Nを制限するラッパー:許可コマンドを絞り込む
    5. inferを用いた型推論と条件型の高度な活用
    6. 表:ジェネリックラッパーの種類と型変換の方向性
    7. 実装上の注意点とトレードオフ
    8. 実践的なユースケース:認証付きAPIクライアントのラッピング
  7. 実プロジェクトで役立つGenerator型の設計パターン 状態遷移とリトライ制御
    1. 状態遷移マシンとしてのGenerator設計パターン
    2. リトライ制御を組み込んだ非同期ワークフローパターン
    3. 状態遷移とリトライ制御を組み合わせた複合パターン
    4. 表:状態遷移パターンとリトライパターンの比較
    5. 設計上の注意点と実装時のトレードオフ
    6. 実装の最終チェックリスト
  8. まとめ:三つの型パラメータを省略せず書く習慣が堅牢なコードを生む
    1. 型パラメータ省略がもたらす負の連鎖を振り返る
    2. 三つの型パラメータをすべて明示するという絶対原則
    3. 各型パラメータにふさわしい具体的な型を選ぶ指針
    4. 型指定を習慣化するための具体的なワークフロー
    5. 型安全性がもたらす副次的効果:ドキュメント代替とリファクタリング耐性
    6. 最後に:Generatorは怖くない、型で制御すれば強力な武器になる

なぜGeneratorの型指定が重要なのか? 型崩れが招く3つのトラブル

TypeScriptのGenerator関数で型エラーが発生し、デバッグに苦戦するプログラマーのモニター画面

TypeScriptのGenerator関数は、イテレーターと双方向通信を融合した独特な制御構造です。
しかし、その表現力の裏には型指定を怠ると深刻な品質問題を引き起こす危険性が潜んでいます。
多くの開発者が「動けばいい」という感覚でyieldを記述した結果、プロジェクトの成長とともに型崩れが顕在化し、デバッグやリファクタリングに膨大な工数を費やすことになります。
ここでは、型指定を軽視した場合に現実的に発生する3つの代表的トラブルを、コンパイル時と実行時の両面から整理します。

第一のトラブルは、yield式の評価結果がunknownとなり、呼び出し側で不用意な型アサーションを強要される点です。
Generatorは next() の戻り値として IteratorResult<T, TReturn> を返しますが、型パラメータを省略すると value の型がunknownになります。
この状態で実際の値を利用するには as string や as User といったキャストが散在し、コードベースが型アサーションだらけになります。
型アサーションはコンパイラによる検証を放棄する行為であり、リファクタリングで構造が変わってもエラーにならず、実行時にプロパティ不存在の例外が発生します。
特に、yieldがオブジェクトを返すケースでは、この問題が顕著です。

第二のトラブルは、next() の引数に任意の値が許容され、制御フローが予測不能になることです。
Generatorは next(param) の引数を前回のyield式の評価値として受け取ります。
型指定がない場合、このparamは unknown となるため、呼び出し側は数値も文字列もオブジェクトも何でも渡せます。
実装側では typeof や in 演算子で実行時チェックを入れるしかなく、チェック漏れがあれば意図しない分岐や無限ループに陥ります。
特に、複数の開発者が関わるプロジェクトでは、「このnextには何を渡せるのか」という暗黙の知識が属人化し、コードレビューでも見落とされがちです。

第三のトラブルは、return文の終了値が型情報を持たず、非同期処理の完了状態を安全に受け取れないことです。
Generatorは return で最終値を返せますが、その型が不明だと、呼び出し側は done: true になった時点でvalueを取得しても、その構造を確かめられません。
例えば、APIのリトライ制御で「成功時はデータ、失敗時はエラーコード」といった具合に終了状態を分けたい場合、型が不明では判別ユニオンを活用できず、結果的に実行時のダックタイピングに頼らざるを得ません。

これらのトラブルは、いずれも型パラメータの省略に起因します。
TypeScriptのGeneratorは Generator<Y, R, N> という三つの型変数を持ちますが、多くの解説では Generator<number> のように最初の一つだけ指定し、残りをunknownに委ねる悪しき習慣が蔓延しています。
この習慣が、以下のような負の連鎖を生みます。

  • 開発初期は問題なくても、機能追加でyieldの種類が増えると、既存の型アサーションが誤った仮定に基づいていることに気づく
  • 静的解析ツールやIDEの補完が効かず、正しいプロパティ名を記憶に頼るしかなくなる
  • ユニットテストではカバーしきれない分岐で実行時エラーが発生し、障害対応に追われる

これを防ぐには、型指定を「コスト」ではなく「設計の契約」として捉える視点が必要です。
Generatorは単なるループ構文ではなく、呼び出し側と実装側の間で交わされるプロトコルです。
そのプロトコルに型を付けることは、API設計にOpenAPIを使うのと同じくらい自然な営みです。
また、型指定によって得られるメリットは、コンパイル時のエラー検出だけに留まりません。
エディタ上のインラインヒントや、go to definition による型定義ジャンプ、さらには大規模リファクタリングでの影響範囲特定など、開発体験全体を向上させます。

では、具体的にどのように型を指定すれば良いのでしょうか。
基本は Generator<YieldType, ReturnType, NextType> の形式で、各パラメータに具体的な型を渡します。
このとき、YieldTypeには実際にyield式が返すオブジェクトの型、ReturnTypeにはreturn文の最終値の型、NextTypeにはnext()に渡せる引数の型を割り当てます。
もしnext引数を使わない設計であれば、void を指定することで意図を明示できます。
この三段階をすべて埋めることで、先述の三つのトラブルは一気に解消されます。

次の章では、この三つの型パラメータのそれぞれが持つ役割と、具体的な指定パターンをコード例と共に詳述しますが、まずは「型指定を省略しない」という原則を徹底することが、Generatorを安全に使いこなす第一歩であると認識してください。
型崩れは小さな妥協の積み重ねで生まれます。
だからこそ、最初の一行目から厳格に向き合うことが、長期的な保守性を約束します。

TypeScriptが提供するGenerator型の基本構造 Y・R・Nの意味を正しく理解する

TypeScriptのGenerator型定義でY,R,Nの三つの型パラメータを図解したスライド

TypeScriptの標準ライブラリには、Generator関数の型を表現するためのジェネリックインターフェースとして Generator<Y, R, N> が定義されています。
この三つの型パラメータはそれぞれ Yield(生成値)、Return(終了値)、Next(受信値) を表し、Generatorの双方向通信を完全に型付けするための基盤となります。
しかし、実務ではこの三つ組を正しく使い分けられているケースは意外に少なく、多くが最初のYだけ指定して残りをunknownに委ねるか、あるいは Generator とだけ書いて事実上のany状態に甘んじています。
本章では、それぞれの型パラメータが担う役割を、コンパイル時の振る舞いと実行時の対応関係から丁寧に解説します。

Y(Yield型):呼び出し側が受け取る値の型

第一型パラメータであるYは、Generator内部で yield 式が評価されたときに、next() メソッドの戻り値である IteratorResult オブジェクトの value プロパティに格納される値の型を指定します。
つまり、Generatorの「出力」を定義する型です。
たとえば、数値を順にyieldする関数であれば Generator<number, any, any> となり、ユーザーオブジェクトをyieldするのであれば Generator<User, any, any> となります。
このYを正しく指定することで、呼び出し側は for...of ループや next().value を介して取得する値の構造を静的に把握でき、プロパティ参照時のタイポや型誤差をコンパイル時に検出できます。

重要なのは、Yはユニオン型やリテラル型と組み合わせることで、Generatorが出力可能な値の集合を制限できる点です。
例えば Generator<"pending" | "fulfilled" | "rejected", ...> とすれば、状態遷移のシグナルとして利用する際に、許容されない文字列がyieldされることを防げます。

R(Return型):Generatorの最終的な終了値の型

第二パラメータRは、Generator関数内で return 文が実行された際に返される最終値の型を指定します。
通常のイテレーターは done: true になると value は undefined になることが多いですが、Generatorは明示的にreturn文で任意の値を返せるという特徴があります。
このRを指定しないと、returnされた値はunknown扱いとなり、呼び出し側が done フラグをもとに最終結果を取得する場面で型安全性が失われます。

実践的には、Rには判別ユニオン型を採用することが強く推奨されます。
例えば { success: true; data: T } | { success: false; error: Error } のように定義すれば、Generatorの終了状態が成功か失敗かを型レベルで表現でき、呼び出し側は if (result.success) による型ガードで安全にデータを取り出せます。
この設計は、非同期処理のラッパーやリトライ制御において極めて有効です。

N(Next型):呼び出し側が渡す入力値の型

第三パラメータNは、おそらく最も理解が進んでいないものの、実務で最も事故を防ぐ効果が高い型パラメータです。
Generatorは next(argument) の引数を、直前の yield 式の評価値として内部に渡すことができます。
この引数の型を指定するのがNです。
デフォルトはunknownですが、ここに具体的な型を設定することで、呼び出し側がnextに渡せる値を厳格に制限できます。

たとえば、nextに "continue" または "abort" という文字列リテラルだけを許可したい場合は、Generator<..., ..., "continue" | "abort"> と指定します。
これにより、呼び出し側が数値やオブジェクトを誤って渡せば即座にコンパイルエラーとなります。
Nを指定しないと、実装側では typeof や in 演算子による実行時バリデーションが必須になり、チェック漏れがバグの原因になります。

三つの型パラメータの関係性と省略時のデフォルト動作

ここで注意すべきは、三つの型パラメータは独立しているようで、実際にはGeneratorの呼び出しフローを通じて密接に連携するという点です。
Yは next() の戻り値のvalueに、Rは最終戻り値に、Nは next() の引数にそれぞれ対応します。
省略した場合のデフォルトはすべてunknownになるため、いずれか一つでも省略すると、その部分で型崩れが生じる余地が残ります。

多くの開発者が Generator<number> とだけ書く習慣がありますが、これはRとNがunknownになるため、return値とnext引数が未チェックのまま放置されることになります。
「使わないから省略する」ではなく、「使わないならvoidを明示する」 というスタンスが重要です。
Nにvoidを指定すれば、nextに引数を渡せないGeneratorとして型が制約され、誤った呼び出しを予防できます。

具体的な型パラメータの指定例とその効果

以下のコードは、Yにログメッセージ型、Rに終了ステータス型、Nにコマンド型をそれぞれ指定した例です。

type LogMessage = { level: 'info' | 'warn'; text: string };
type FinalStatus = { finished: true; count: number } | { finished: false; reason: string };
type Command = 'proceed' | 'skip' | 'retry';

function* logger(): Generator<LogMessage, FinalStatus, Command> {
  let index = 0;
  while (index < 3) {
    const cmd = yield { level: 'info' as const, text: `Loop ${index}` };
    if (cmd === 'skip') { index++; continue; }
    if (cmd === 'retry') { continue; }
    if (cmd === 'proceed') { index++; }
  }
  return { finished: true, count: 3 };
}

この定義により、呼び出し側はnextに 'proceed' のみを渡せるわけではなく、ユニオン型に含まれる三つの文字列すべてが許可されますが、それ以外の値(例えば数値や 'abort')はエラーになります。
また、yieldで受け取るLogMessageは確実にlevelとtextを持ち、最終的なreturnは判別ユニオンで処理できます。

型パラメータ指定時の落とし穴と回避策

一つ注意すべきは、Nの型は next() の引数に適用されるため、Generatorの初回呼び出しである next() には引数を渡しても無視されるという仕様です。
しかし型レベルでは初回も含めてNがチェックされるため、初回に引数を渡すコードは型エラーになります。
これを回避するには、Generatorをラップする関数で初回のnextだけは引数なしで呼び出すよう制御するか、Nに void | Command のようにundefinedを許容するユニオン型を設定します。

また、YとNが同じ型になるケースでは、型エイリアスを定義して可読性を高めるのも有効です。
type GeneratorIO<T> = Generator<T, void, T> のように抽象化すれば、入出力が同型の単純なパイプラインを簡潔に表現できます。

次の章では、この基本構造を踏まえた上で、特にYとNをユニオン型やリテラル型で厳格化する実践パターンを、コードの拡張性やテスト容易性と絡めて詳述します。
まずは、三つの型パラメータを「すべて埋める」ことを新たなデフォルトとして採用してください。
それが型安全なGenerator利用の第一原則です。

yieldの戻り値を厳格に指定する実践パターン ユニオン型とリテラル型の活用法

ユニオン型を用いてyieldの戻り値を制限するTypeScriptのコード例と型推論の結果

前章では Generator<Y, R, N> の三つの型パラメータの役割を概観しましたが、実務で最も頻繁に直面するのが「Y(yieldの戻り値)」の設計です。
このYをいかに厳格かつ柔軟に定義するかによって、Generatorの呼び出し側の開発体験が大きく左右されます。
本章では、ユニオン型とリテラル型を中心に据えたYの指定パターンを、実際のユースケースとともに解説します。
単に Generator<string> と指定するのではなく、ドメインに即した型を設計することで、コードの自己文書化とコンパイル時検証の両立を図ります。

リテラル型で状態遷移を表現するパターン

Generatorを状態マシンとして利用する場合、yieldされる値は「現在の状態」を表すことが多く、その取り得る値は有限集合です。
こうしたケースでは、文字列リテラルのユニオン型をYに指定するのが最もシンプルかつ効果的です。

type LoadState = 'idle' | 'loading' | 'success' | 'error';

function* loadStateMachine(): Generator<LoadState, void, void> {
  yield 'idle';
  yield 'loading';
  // 何らかの非同期処理
  yield 'success';
  // または yield 'error';
}

この定義により、呼び出し側は next().value が必ず4つの文字列リテラルのいずれかであると保証され、switch 文や if 文での網羅性チェックが機能します。
TypeScriptのコンパイラはユニオン型の未処理ケースを警告するため、状態の追加漏れも発見しやすくなります。

オブジェクト型とユニオン型を組み合わせて詳細情報を付与するパターン

状態だけでなく、それに付随するデータも同時にyieldしたい場合は、オブジェクト型を要素とするユニオン型を採用します。
このパターンは、APIクライアントやファイル読み込み処理など、進行状況と結果を逐次報告する場面で有効です。

type ProgressReport = 
  | { stage: 'start'; total: number }
  | { stage: 'chunk'; current: number; data: Buffer }
  | { stage: 'done'; hash: string };

このYを指定することで、呼び出し側は stage プロパティを型ガードに使い、各ステージごとに安全にデータを取り出せます。
stage が 'chunk' の場合にのみ data プロパティが存在することが型レベルで保証されるため、不要なオプショナルチェーンや実行時アサーションを削減できます。

判別可能なユニオン型(Discriminated Union)を用いた高度な設計

上記のパターンをさらに推し進めると、判別プロパティ(共通のリテラルフィールド) を持つユニオン型が理想的です。
TypeScriptはこのパターンをネイティブにサポートしており、switch 文で絞り込むと自動的に各ケースのプロパティが補完されます。

type StreamEvent = 
  | { kind: 'data'; payload: string; index: number }
  | { kind: 'error'; error: Error; fatal: boolean }
  | { kind: 'end'; timestamp: number };

function* eventStream(): Generator<StreamEvent, void, void> {
  yield { kind: 'data', payload: 'Hello', index: 1 };
  yield { kind: 'data', payload: 'World', index: 2 };
  yield { kind: 'end', timestamp: Date.now() };
}

呼び出し側では event.kind で分岐するだけで、payload や error へのアクセスが型安全になります。
この設計は、Generatorをイベントソーシングやログストリームのプロデューサーとして利用する際のデファクトスタンダードと言えます。

ジェネリック型を導入して汎用的なyield型を再利用する

複数のGeneratorで同じようなyieldパターンが出現する場合、型パラメータを持つジェネリックエイリアスを定義することで重複を排除できます。
たとえば、ページネーション付きのデータ取得をyieldするなら、以下のように抽象化します。

type PaginatedYield<T> = 
  | { page: number; items: T[]; hasMore: true }
  | { page: number; items: T[]; hasMore: false; nextToken?: string };

これを Generator<PaginatedYield<User>, ...> や Generator<PaginatedYield<Product>, ...> のように使い回すことで、一貫性のある型設計が実現します。
ジェネリックを活用すれば、yieldの構造は共通だが中身のデータ型だけ異なるというシナリオに柔軟に対応できます。

実践上の注意点と型の拡張に備えた設計

Yを厳格に指定する際の落とし穴として、後から新しいケースを追加する場合の拡張性が挙げられます。
ユニオン型に新しいリテラルを追加すると、既存の呼び出し側で switch の網羅性チェックが新たなケースを未処理として警告するため、これはむしろ望ましい動作です。
しかし、サードパーティに公開するライブラリの場合は、exact なユニオン型よりもベースインターフェースと拡張用の交差型を検討する価値があります。

また、yieldされる値が非常に多様で数十種類に及ぶ場合は、ユニオン型が肥大化しすぎるため、代わりにタグ付きのラッパー型や、type フィールドで大分類した階層型を設計することを推奨します。
型の複雑さとメンテナンス性はトレードオフの関係にあるため、チームの規模や変更頻度に応じて適切な粒度を選定してください。

表:yield型の設計パターンと適したユースケース

パターン 型定義例 推奨ユースケース 拡張性
単一リテラル 'ok' 単純な完了シグナル 低
リテラルユニオン 'start' \| 'progress' \| 'done' 状態遷移の報告 中
オブジェクトユニオン {type:'data'; value:T} \| {type:'error'; err:E} データ+メタデータの同時伝達 高(判別子追加で拡張)
ジェネリック型エイリアス PaginatedYield<T> ページネーションやバッチ処理 非常に高い

この表からも分かるように、オブジェクトユニオン+判別子のパターンが最もバランスが良く、実務での採用頻度も高いです。
ただし、シンプルなユースケースでは過剰設計にならないよう、まずはリテラルユニオンから始めて、後からオブジェクト化するリファクタリングも視野に入れてください。

Yの型設計は、Generatorの「出力仕様書」です。
この仕様書が明確であればあるほど、呼び出し側の実装者はドキュメントを読まずとも型定義から使い方を直感的に理解できます。
次の章では、Yと対をなすN(next引数の型)に焦点を当て、外部からの入力制御を型で強制する手法を掘り下げます。

next()引数の型を制限して外部入力の危険を排除する防御的設計

nextメソッドの引数に型制約を設けて不正な値の流入を防ぐTypeScriptの型チェック画面

Generator関数の特徴でありながら、多くのTypeScript開発者が軽視しがちなのが、next() メソッドに引数を渡してGenerator内部に値を送り込む機能です。
この双方向通信こそがGeneratorの真価を発揮する場面ですが、型指定を怠ると外部からの入力が無制限に許容され、結果として実行時の予期せぬ分岐や、セキュリティ上のインジェクションリスクさえ招きます。
本章では、第三型パラメータNを活用して、next() の引数を厳格に制限する防御的設計パターンを、エッジケースも含めて体系的に解説します。

Nの本質とデフォルト動作がもたらす危険性

Nは Generator<Y, R, N> の第三引数として、next(arg) の arg に許容される型を定義します。
何も指定しない場合、この型は unknown となります。
unknownはそれ自体は安全な型ですが、呼び出し側は next(42) も next("hello") も next({ foo: true }) もすべてコンパイルを通してしまいます。
Generator内部では、受け取った値を typeof や instanceof、in 演算子で判別するしかなく、チェック漏れがあればそのまま誤った制御フローに突入します。

特に、複数の開発者が関わるプロジェクトでは、「このnextには何を渡せるのか」という暗黙の契約がドキュメント化されず、コードレビューでも見落とされがちです。
Nを void にすれば引数そのものを禁止できますが、それ以外のケースでは必ず具体的な型を設定すべきです。

文字列リテラルユニオンでコマンドパターンを実装する

最も実用的なパターンの一つが、nextに渡す値をコマンド文字列のユニオン型に限定する方法です。
これにより、Generatorは外部から「指示」を受け取り、その指示に応じて内部の動作を変更できます。

type Command = 'next' | 'skip' | 'reset' | 'abort';

function* taskProcessor(): Generator<string, void, Command> {
  let count = 0;
  while (true) {
    const cmd = yield `Current count: ${count}`;
    switch (cmd) {
      case 'next': count++; break;
      case 'skip': count += 2; break;
      case 'reset': count = 0; break;
      case 'abort': return;
    }
  }
}

この例では、呼び出し側は 'next'、'skip'、'reset'、'abort' のいずれかのみを渡せます。
'stop' や 'pause' など未定義の文字列はコンパイルエラーとなり、想定外のコマンドによる状態破壊を未然に防ぎます。

オブジェクト型で複雑なパラメータを構造化して渡す

コマンドに付随するデータが必要な場合は、オブジェクト型をNに指定することで、複数のパラメータを構造化して渡せます。
これにより、引数の数が増えても型定義が整理され、省略可能なプロパティも表現できます。

type UpdateCommand = 
  | { action: 'increment'; step: number }
  | { action: 'decrement'; step: number }
  | { action: 'set'; value: number }
  | { action: 'reset' };

function* counter(): Generator<number, void, UpdateCommand> {
  let current = 0;
  while (true) {
    const cmd = yield current;
    switch (cmd.action) {
      case 'increment': current += cmd.step; break;
      case 'decrement': current -= cmd.step; break;
      case 'set': current = cmd.value; break;
      case 'reset': current = 0; break;
    }
  }
}

呼び出し側は next({ action: 'increment', step: 5 }) のように型安全に渡せ、step の型が数値であることも保証されます。
reset の場合は追加プロパティが不要なため、型定義が自然にその制約を表現しています。

ユニオン型と判別子を組み合わせた拡張性の高い設計

前述の例では action を判別子とする判別ユニオン型を採用しました。
このパターンは、新しいコマンドの追加が容易でありながら、既存の呼び出し側に影響を与えずに拡張できる点が優れています。
TypeScriptのコンパイラは switch 文で cmd.action を絞り込むため、各ケースごとに正しいプロパティだけが補完されます。

また、Nにユニオン型を指定する場合、ジェネリック型エイリアスを定義して複数のGeneratorで再利用することも有効です。

type Controller<T> = { action: 'set'; payload: T } | { action: 'clear' };
type GeneratorWithSet<T> = Generator<T, void, Controller<T>>;

これにより、GeneratorWithSet<string> や GeneratorWithSet<number[]> のように、データ型だけを変えた複数のGeneratorを統一的に扱えます。

初回next()呼び出しにおける型制約の例外とその対処法

TypeScriptの仕様上、next() の初回呼び出しに引数を渡してもGeneratorはその値を受け取りません(初回のyieldよりも前に実行されるコードがないため)
しかし型レベルではNの制約が初回にも適用されるため、next('some') が許可されていても実際には無視されるという不一致が生じます。
この混乱を避けるには、以下の戦略を取ります。

  • Nに void と実際のユニオン型の両方を含める(void | Command)ことで、初回は引数なし、2回目以降はコマンドを渡す設計とする
  • Generatorをラップする関数を用意し、初回のnextだけを内部で自動呼び出しする
  • ドキュメントやJSDocで「初回引数は無効」と明記し、チーム内で合意する

推奨は void | Command パターンです。
これなら初回に引数を渡しても型エラーにならず、かつ2回目以降はコマンドのみが許容されるため、実運用上の柔軟性と型安全性を両立できます。

表:Nの型設計パターンと適用シーン

パターン 型定義例 メリット デメリット 適応ユースケース
void(引数禁止) Generator<Y, R, void> 入力を受け付けず純粋な出力専用にできる 双方向性を活かせない ログ出力、センサーデータ送信
リテラルユニオン 'start' \| 'stop' シンプルで判別が容易 付随データを持てない 単純な制御フラグ
オブジェクトユニオン {op:'add'; n:number} \| {op:'sub'; n:number} 複合データを構造化して渡せる 型定義がやや冗長になる 計算機、設定更新
判別ユニオン+ジェネリック Controller<T> 拡張性と再利用性が高い 学習コストがやや高い 大規模ライブラリ、フレームワーク

実務で気をつけるべきエッジケース

Nを制限する際に見過ごされがちなのが、外部からの入力がユーザー入力やAPIレスポンスなど信頼できないソースから来るケースです。
型制限はあくまでコンパイル時の静的チェックであり、実行時に型アサーションをすり抜けて不正な値が渡される可能性はゼロではありません。
そのため、Nを制限しても、Generator内部では必ず実行時バリデーションを併用することを推奨します。
特に、Nのユニオン型に含まれない値が渡された場合は、早期に例外をスローするか、デフォルト動作にフォールバックする設計を組み込んでください。

また、Nに any や unknown を指定するのは絶対に避けてください。
それは型制限を放棄するのと同義であり、防御的設計の根幹を崩します。
どうしても動的に渡す値が決まらない場合は、unknown を指定した上で、内部で is 型ガードを用いた厳格なバリデーション関数を経由させるようにしてください。

次の章では、return文の最終値の型設計に移ります。
Generatorの終了状態を型で表現することで、非同期処理の完了パターンを統一的に扱う手法を解説します。
まずはNに対して「何を渡せるか」ではなく「何を渡させないか」という視点を持ち、外部入力に対して常に警戒する姿勢を型で表現してください。

return文の最終値まで型安全に扱う 終了状態を判別ユニオンで表現する

Generatorのreturn文で成功と失敗を判別ユニオン型として返すTypeScriptの実装例

Generator関数の実行が完了したとき、return 文によって最終値を返すことができます。
この最終値は、イテレーターの done プロパティが true になった時点で value から取得可能です。
しかし、多くの開発者はこの戻り値を軽視し、return を単なる「処理の終了」としか捉えていません。
その結果、終了状態に関する型情報が欠落し、呼び出し側は done: true になった後の値の扱いに困ることになります。
本章では、第二型パラメータRを活用して、Generatorの終了状態を判別ユニオン型で厳格に表現する方法と、それによって得られる保守性・可読性の向上について詳述します。

R(Return型)が省略されることの代償

TypeScriptの Generator<Y, R, N> における第二パラメータRは、return 文が返す値の型を指定します。
このRを省略するとデフォルトで unknown となり、呼び出し側は result.value を取得してもその構造が一切わかりません。
たとえば、APIからデータを取得するGeneratorが「成功時はデータ、失敗時はエラー情報」を返したい場合、Rがunknownでは型ガードを使えず、as による強制キャストや any への逃避が発生します。
これは、Generatorの最終結果という重要な情報を型システムから切り離すことに他なりません。

判別ユニオン型による終了状態の表現

Rに指定する最も実用的なパターンは、判別ユニオン型(Discriminated Union) です。
共通の判別プロパティ(通常は kind や status)を持たせた複数のオブジェクト型をユニオンでまとめることで、終了状態が成功・失敗・キャンセルなど複数のシナリオをとる場合にも、型安全にハンドリングできます。

type FinalResult<T> = 
  | { kind: 'success'; data: T; duration: number }
  | { kind: 'failure'; error: Error; retryCount: number }
  | { kind: 'canceled'; reason: string };

function* fetchWithRetry<T>(url: string): Generator<Promise<T>, FinalResult<T>, void> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const response = yield fetch(url).then(r => r.json() as T);
      return { kind: 'success', data: response, duration: attempt * 100 };
    } catch (e) {
      if (attempt === 3) {
        return { kind: 'failure', error: e as Error, retryCount: attempt };
      }
    }
  }
  return { kind: 'canceled', reason: 'Max retries exceeded without explicit success' };
}

この定義により、呼び出し側は result.kind で分岐するだけで、各ケースに固有のプロパティ(data、error、reason など)が型安全にアクセスできます。
kind が 'success' の場合にのみ data が存在することが保証されるため、不要なオプショナルチェーンや実行時チェックを大幅に削減できます。

非同期処理との連携におけるRの役割

Generatorは非同期処理と組み合わせて使われることが多く、その際にRはPromiseの解決結果を最終値として受け渡す役割を担います。
上の例では yield fetch(...) でPromiseをyieldし、その解決値を待ってからreturnしていますが、このパターンではRの型が非同期フローの完了契約として機能します。

さらに、Rをジェネリック型パラメータとして外部から注入できるように設計すれば、Generator自体を汎用的な非同期ワークフローエンジンとして再利用できます。

type AsyncResult<T, E = Error> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

function* asyncWrapper<T, E>(
  task: () => Promise<T>
): Generator<Promise<T>, AsyncResult<T, E>, void> {
  try {
    const value = yield task();
    return { ok: true, value };
  } catch (e) {
    return { ok: false, error: e as E };
  }
}

この設計では、ok フラグが判別子となり、成功時と失敗時で型が完全に分離されます。
呼び出し側は if (result.ok) でガードするだけで、value または error に安全にアクセスできます。

return文を複数持つ場合の型整合性

Generator内で複数の return 文が存在する場合、それらがすべて同じRの型に代入可能である必要があります。
判別ユニオン型を採用すれば、各return文で異なるケースを返すことが自然に許容されます。
TypeScriptはユニオン型の代入整合性をチェックするため、例えば kind: 'success' のオブジェクトを返すべき場所で誤って kind: 'failure' を返すとコンパイルエラーになります。

ただし、return文が存在しないGeneratorではRは void として扱われるため、その場合は Generator<Y, void, N> と明示的に指定すると意図が伝わりやすくなります。

実行時エラーとreturn型の関係

Rを厳格に指定しても、Generator内部でスローされた例外は型システムの対象外です。
そのため、例外が発生した場合の終了状態はRで表現できません。
このギャップを埋めるには、例外をキャッチしてRの失敗ケースとして変換するか、Generator全体をtry-catchでラップする設計を採用します。
判別ユニオンに { kind: 'exception'; error: unknown } のようなケースを追加しておけば、予期せぬエラーも含めてすべての終了パターンを型で網羅できます。

表:Rの設計パターンと推奨ユースケース

パターン 型定義例 メリット デメリット 推奨シーン
void(終了値なし) Generator<Y, void, N> シンプルで余計な考慮が不要 終了状態を呼び出し側に伝えられない ログ出力、無限ストリーム
単一型 Generator<Y, number, N> 終了値が一意で明確 成功/失敗の区別ができない カウンター、集計処理
単純ユニオン 'ok' \| 'error' 区別が容易 付随データを持てない 単純な成否フラグ
判別ユニオン(推奨) {kind:'ok'; data:T} \| {kind:'err'; err:E} データと状態を同時に型安全に扱える 定義がやや冗長 API呼び出し、ファイル処理、バッチジョブ

実務上のベストプラクティス

  • Rには常に判別ユニオン型を指定し、kind または status という判別プロパティを用意する。これにより、呼び出し側のswitch文が網羅性チェックの恩恵を受けられます
  • 非同期処理の場合は、RにPromiseの解決値型ではなく、解決後の最終状態型を直接指定する。yield でPromiseを扱う場合、Rはその非同期処理の「完了結果」を表すべきであり、Promise自体の型ではないことに注意してください
  • ジェネリック型を使ってRを外部化し、再利用可能な結果型を定義する。AsyncResult<T> や FinalResult<T> のような型をプロジェクト全体で共通化すれば、Generator間での一貫性が向上します
  • return文のないGeneratorには明示的に void を指定する。省略するとunknownになるため、意図せぬ型アサーションを誘発します

Rの設計は、Generatorの「最終報告書」です。
この報告書が曖昧であれば、呼び出し側は終了後の処理を推測で書くしかなくなり、品質は低下します。
逆に、判別ユニオンで明確に定義すれば、終了状態は文書化された仕様そのものとなり、コードの信頼性が飛躍的に向上します。
次の章では、これらのY・R・Nを統合的に扱うジェネリックラッパー関数のテクニックを紹介します。

ジェネリックラッパー関数でGenerator型を派生させる高度な型操作テクニック

高階関数を使ってGenerator型を変換するTypeScriptのジェネリック型定義のコード

ここまでの章では、Generator型の三つのパラメータY・R・Nを個別に厳格指定する方法を解説してきました。
しかし実務では、複数のGeneratorに共通する型パターンを再利用したり、既存のGeneratorを別の型コンテキストでラップしたりする場面が頻繁に発生します。
そうした際に威力を発揮するのが、ジェネリックラッパー関数を用いた型派生テクニックです。
本章では、高階関数や条件型、infer キーワードを駆使して、Generator型を安全に変換・拡張する高度な手法を、実用的なコード例とともに紹介します。

ラッパー関数が解決する三つの課題

Generator型を直接扱うだけでは対処しきれない課題として、以下の三つが代表的です。

  • 複数のGeneratorでYやRのパターンが共通する場合、毎回同じユニオン型を手書きするのは非効率
  • 非同期処理やエラーハンドリングをGeneratorに組み込む際、元の型を変えずに拡張したい
  • サードパーティ製のGeneratorが返す型がunknownベースで、自前の型システムに適合させたい

これらの課題に対し、ジェネリックラッパー関数は 「型の変換パイプライン」 を提供します。
入力となるGenerator型を受け取り、出力として別のGenerator型を返す関数を型レベルで定義することで、コードの重複を排除しつつ型安全性を維持できます。

基本的なラッパーパターン:Yを変換する

最もシンプルなユースケースは、Generatorがyieldする値(Y)を別の型にマッピングすることです。
たとえば、数値をyieldするGeneratorを文字列に変換してラップする場合を考えます。

function wrapToString<T, R, N>(
  gen: Generator<T, R, N>
): Generator<string, R, N> {
  return (function* () {
    const result = yield* gen;
    // yield* で委譲すると、各yield値がTからstringに変換される
    // ただし、これは擬似的な例示のため、実際にはyield*の戻り値を加工する
    return result;
  })() as Generator<string, R, N>;
}

より現実的には、yield* を使って元のGeneratorを委譲しつつ、各yield値を加工するには、手動でイテレーションを回す必要があります。

function mapYield<T, U, R, N>(
  gen: Generator<T, R, N>,
  mapper: (value: T) => U
): Generator<U, R, N> {
  return (function* () {
    let next = gen.next();
    while (!next.done) {
      yield mapper(next.value);
      next = gen.next();
    }
    return next.value;
  })();
}

この関数は、元のGeneratorのY型 T を U に変換し、RとNはそのまま保持します。
型パラメータをすべて明示することで、変換前後でYだけが変わり、他の契約は損なわれないことを保証します。

Rを拡張するラッパー:終了状態にログ情報を付与する

次に、Return型(R)を拡張するパターンです。
元のGeneratorが返す最終結果に、実行時間やコンテキスト情報を追加したい場合に有効です。

type LoggedResult<R> = R extends void ? { logged: true; duration: number } : R & { logged: true; duration: number };

function withLogging<Y, R, N>(
  gen: Generator<Y, R, N>
): Generator<Y, LoggedResult<R>, N> {
  return (function* () {
    const start = Date.now();
    const result = yield* gen;
    const duration = Date.now() - start;
    // Rがvoidの場合はオブジェクトを返し、それ以外は既存の結果に拡張
    if (result === undefined) {
      return { logged: true, duration } as LoggedResult<R>;
    }
    return { ...result, logged: true, duration } as LoggedResult<R>;
  })();
}

この例では、条件型 LoggedResult<R> を定義し、Rがvoidかどうかで返す構造を変えています。
こうすることで、元の終了値の型情報を失わずに、追加フィールドだけを安全に付与できます。
呼び出し側は result.logged や result.duration にアクセス可能で、元のプロパティもそのまま利用できます。

Nを制限するラッパー:許可コマンドを絞り込む

次に、Next型(N)をより狭い型に制限するラッパーです。
外部からの入力を制限したいが、元のGeneratorは広いNを持っている場合に使います。

function restrictCommand<Y, R, N extends string>(
  gen: Generator<Y, R, N>,
  allowed: N[]
): Generator<Y, R, N> {
  // 実行時にも許可リストをチェックするプロキシを返す
  // 型レベルでは制限できないため、実行時バリデーションと合わせる
  return (function* () {
    const iterator = gen;
    let next = iterator.next();
    while (!next.done) {
      const current = yield next.value;
      if (current !== undefined && !allowed.includes(current)) {
        throw new Error(`Command ${current} is not allowed`);
      }
      next = iterator.next(current);
    }
    return next.value;
  })();
}

型パラメータNは元のユニオン型のままで、型レベルでは絞り込めないことに注意してください。
このケースでは、型よりも実行時制約が主目的となります。
型レベルで制限したい場合は、ラッパー関数自体に別の型パラメータ Narrow extends N を導入し、返り値のNをNarrowに固定する手法が取れます。

inferを用いた型推論と条件型の高度な活用

より複雑なシナリオでは、TypeScriptの infer キーワードを使ってGenerator型からY・R・Nを抽出し、それらを加工して新しい型を生成します。

type ExtractY<T> = T extends Generator<infer Y, any, any> ? Y : never;
type ExtractR<T> = T extends Generator<any, infer R, any> ? R : never;
type ExtractN<T> = T extends Generator<any, any, infer N> ? N : never;

function wrapWithFallback<G extends Generator<any, any, any>>(
  gen: G,
  fallback: ExtractR<G>
): Generator<ExtractY<G> | 'fallback', ExtractR<G>, ExtractN<G>> {
  return (function* () {
    const result = yield* gen;
    // 何らかの条件でfallbackを返す
    return result ?? fallback;
  })();
}

このように、型パラメータGに制約を課し、そこから内部的にY・R・Nを抽出することで、ラッパーは元のGeneratorの具体的な型を知らなくても適切な型変換を適用できます。
これは、ライブラリ開発やプラグイン機構において極めて有用です。

表:ジェネリックラッパーの種類と型変換の方向性

ラッパーの目的 変更する型パラメータ キーテクニック 適用シーン
値のマッピング Y 手動イテレーションでyield値を加工 APIレスポンスの変換、フォーマット統一
終了状態の拡張 R 条件型と交差型で追加フィールドを付与 ログ、トレース、パフォーマンス計測
入力制限の強化 N(実行時) 許可リストによるガード セキュリティ、ドメイン制約
型抽出と再構築 すべて(infer使用) conditional type + infer ジェネリックなミドルウェア、デコレーター

実装上の注意点とトレードオフ

ジェネリックラッパーは強力ですが、型の複雑さが増すという代償があります。
過度に抽象化すると、エラーメッセージが読みづらくなり、IDEの補完が遅延することもあります。
そのため、以下のガイドラインを推奨します。

  • ラッパーは一つの責任に絞る(Y変換だけ、R拡張だけなど)。複数の変換を一度に行うとデバッグが困難になります
  • ラッパーを利用する側は、変換後の型が直感的に理解できるように、型エイリアスを併用して戻り値型に名前を付けます
  • どうしても型推論が複雑になる場合は、as アサーションをラッパー内部に限定し、外部インターフェースは完全に型安全な状態を保ちます
  • ユニットテストでは、ラッパーが元のGeneratorの振る舞いを変更していないことを確認するために、型レベルだけでなく実行時の振る舞いも検証してください

実践的なユースケース:認証付きAPIクライアントのラッピング

最後に、これまで紹介したテクニックを統合した実践例を示します。
認証トークンを自動更新しながらAPIを呼び出すGeneratorに対し、リトライロジックとログ出力をラッパーで追加する設計です。

type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
type ApiGen<T> = Generator<Promise<T>, ApiResult<T>, 'retry' | 'abort'>;

function withRetryAndLog<T>(
  gen: ApiGen<T>
): Generator<Promise<T>, ApiResult<T> & { retries: number }, 'retry' | 'abort'> {
  let retryCount = 0;
  return (function* () {
    while (true) {
      const result = yield* gen;
      if (result.ok) {
        return { ...result, retries: retryCount };
      }
      retryCount++;
      if (retryCount > 3) {
        return { ...result, retries: retryCount };
      }
      // retryコマンドを強制するため、次のnextに'retry'を渡す
      const cmd = yield 'retry' as any; // 実際は型ガードを経由
      if (cmd === 'abort') {
        return { ...result, retries: retryCount };
      }
    }
  })();
}

このラッパーは、元のGeneratorのY(Promise)をそのままに、Rに retries フィールドを追加し、Nは元のコマンドを維持します。
型パラメータを明示することで、変換の前後で契約が破綻しないことを保証しています。

ジェネリックラッパーは、Generator型を部品として再利用するための強力な道具です。
ただし、過剰な抽象化は避け、必要な箇所に限定して適用することで、型安全性とコードの明快さを両立させてください。
次の章では、これらのテクニックを実際のプロジェクト設計に落とし込む具体的なパターンを紹介します。

実プロジェクトで役立つGenerator型の設計パターン 状態遷移とリトライ制御

APIリトライ制御や状態マシンをGeneratorで実装したTypeScriptの設計図

ここまでの章では、Generator型の各パラメータの厳格指定方法と、ジェネリックラッパーによる型派生テクニックを解説してきました。
これらの知識を実際のプロダクト開発に落とし込む際、最も頻繁に直面するのが状態遷移の管理と外部API呼び出しのリトライ制御という二大ユースケースです。
本章では、これら二つの領域に焦点を絞り、Generator型を活用した実践的な設計パターンを、型安全性と保守性を両立する形で提案します。
具体的なコード例を通じて、理論を実装に結びつけるための指針を提供します。

状態遷移マシンとしてのGenerator設計パターン

状態遷移は、UIの画面フロー、通信プロトコルのハンドシェイク、バッチジョブのフェーズ管理など、あらゆるドメインに登場する基本的な抽象化です。
Generatorを状態マシンとして利用する最大の利点は、状態の遷移を逐次的なyieldとして表現でき、かつ外部からの入力(next引数)で遷移先を制御できる点にあります。

典型的なパターンとして、Yには現在の状態を表す判別ユニオン型を、Nには遷移トリガーとなるコマンド型を、Rには最終的な終了状態を指定します。
これにより、状態の入力・出力・終了のすべてが型レベルで契約されます。

type StepState = 
  | { phase: 'idle'; message: string }
  | { phase: 'processing'; progress: number; item: string }
  | { phase: 'confirming'; pendingAction: string }
  | { phase: 'complete'; result: string };

type StepCommand = 'start' | 'next' | 'confirm' | 'cancel';
type StepFinal = { finished: true; outcome: 'success' | 'aborted' };

function* workflow(): Generator<StepState, StepFinal, StepCommand> {
  let state: StepState = { phase: 'idle', message: 'Ready' };
  while (true) {
    const cmd = yield state;
    switch (state.phase) {
      case 'idle':
        if (cmd === 'start') {
          state = { phase: 'processing', progress: 0, item: 'Task A' };
        }
        break;
      case 'processing':
        if (cmd === 'next') {
          const newProgress = state.progress + 25;
          if (newProgress >= 100) {
            state = { phase: 'confirming', pendingAction: 'Finalize' };
          } else {
            state = { ...state, progress: newProgress };
          }
        } else if (cmd === 'cancel') {
          return { finished: true, outcome: 'aborted' };
        }
        break;
      case 'confirming':
        if (cmd === 'confirm') {
          state = { phase: 'complete', result: 'Done' };
        } else if (cmd === 'cancel') {
          return { finished: true, outcome: 'aborted' };
        }
        break;
      case 'complete':
        return { finished: true, outcome: 'success' };
    }
  }
}

この設計の優れている点は、状態の種類と遷移条件がコード上に明示され、かつTypeScriptの型チェックが各フェーズでアクセス可能なプロパティを保証することです。
例えば phase: 'processing' のときだけ progress が存在し、phase: 'idle' のときは存在しないことが型で強制されます。

リトライ制御を組み込んだ非同期ワークフローパターン

次に、外部APIやデータベース操作など、失敗し得る非同期処理をGeneratorでラップするパターンです。
ここでは、リトライ回数・バックオフ戦略・最終的な成功/失敗の報告を型安全に実装します。

type RetryState<T> = 
  | { status: 'attempt'; attempt: number; delay: number }
  | { status: 'waiting'; until: number }
  | { status: 'resolved'; data: T }
  | { status: 'rejected'; error: string; attemptsUsed: number };

type RetryCommand = 'retry' | 'abort' | { customDelay: number };
type RetryFinal<T> = 
  | { kind: 'success'; value: T; totalAttempts: number }
  | { kind: 'failure'; error: string; totalAttempts: number };

function* retryableFetch<T>(
  url: string,
  maxRetries: number = 3
): Generator<RetryState<T>, RetryFinal<T>, RetryCommand> {
  let attempt = 0;
  let lastError = '';

  while (attempt <= maxRetries) {
    attempt++;
    const delay = 1000 * Math.pow(2, attempt - 1);
    yield { status: 'attempt', attempt, delay } as RetryState<T>;

    try {
      const response = yield fetch(url).then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json() as Promise<T>;
      });
      // 成功時は即座に完了
      return { kind: 'success', value: response, totalAttempts: attempt };
    } catch (e) {
      lastError = (e as Error).message;
      if (attempt > maxRetries) {
        return { kind: 'failure', error: lastError, totalAttempts: attempt };
      }
      // リトライ前に待機コマンドをyield
      const command = yield { status: 'waiting', until: Date.now() + delay } as RetryState<T>;
      if (command === 'abort') {
        return { kind: 'failure', error: 'Aborted by user', totalAttempts: attempt };
      }
      if (typeof command === 'object' && 'customDelay' in command) {
        // カスタム遅延を反映させるため、再度waiting状態をyield(実装は簡略化)
        yield { status: 'waiting', until: Date.now() + command.customDelay } as RetryState<T>;
      }
    }
  }
  return { kind: 'failure', error: 'Exhausted retries', totalAttempts: maxRetries + 1 };
}

このパターンでは、Yに RetryState<T> を指定することで、呼び出し側は現在の試行回数や待機時間をリアルタイムに把握できます。
Nには RetryCommand を指定し、'retry'・'abort'・カスタム遅延オブジェクトのいずれかを渡せるようにしています。
Rには判別ユニオンで成功/失敗を明確に区別し、totalAttempts というメタ情報も付与しています。

状態遷移とリトライ制御を組み合わせた複合パターン

実際のプロジェクトでは、状態遷移の中でリトライが必要になるケースが少なくありません。
例えば、ファイルアップロードのワークフローで「認証→アップロード→確認」という各フェーズがそれぞれリトライ可能である場合、状態ごとに異なるリトライ戦略を持たせる必要があります。

この場合、上位の状態マシンGeneratorが下位のリトライ可能Generatorを呼び出す階層構造を取ります。
型パラメータは階層ごとに独立して指定し、各層のY・R・Nを明確に分離します。
これにより、各レイヤーの責務が型によって可視化され、変更影響範囲も局所化されます。

表:状態遷移パターンとリトライパターンの比較

パターン Y(出力)の特徴 N(入力)の特徴 R(終了)の特徴 主なユースケース
単純状態遷移 状態オブジェクト(判別ユニオン) トリガーコマンド(リテラルユニオン) 成否のみ(booleanまたはenum) UIウィザード、プロトコルハンドシェイク
リトライ制御(非同期) 進捗情報(attempt, delay) 再試行/中止/カスタム遅延 成功データまたはエラー+試行回数 APIクライアント、ファイルダウンロード
階層型ワークフロー サブフローの状態を集約 上位コマンドと下位コマンドの変換 集約された最終結果 バッチ処理、マイクロサービスオーケストレーション

設計上の注意点と実装時のトレードオフ

これらのパターンを実装する際、いくつかの重要なトレードオフが存在します。

  • 状態数が増加すると、switch文が巨大化します。その場合は状態をクラスやストラテジーパターンで置き換えることを検討しますが、その場合でもGeneratorの型パラメータは外部インターフェースとして維持し、内部実装を隠蔽できます
  • リトライロジックはテストが難しいため、リトライ用のGeneratorと実際の処理用のGeneratorを分離し、前者はモック可能な設計にしてください
  • N(next引数)に依存しすぎると、呼び出し側の負担が増えます。可能な限りデフォルト動作を用意し、外部からの入力をオプショナルにする設計を心がけます
  • R(最終値)が複雑になりすぎる場合は、終了状態をイベントとしてyieldで流し、最後にvoidをreturnするという代替設計も検討してください。これにより、終了処理を呼び出し側で柔軟にハンドリングできます

実装の最終チェックリスト

プロジェクトでこれらのパターンを採用する前に、以下の項目を確認してください。

  • Y・R・Nの三つ組がすべて明示的に指定されているか
  • 判別ユニオンには必ずリテラル型の判別子(kind、phase、status など)が含まれているか
  • リトライ時に無限ループに陥る可能性がないか(最大試行回数の設定)
  • 状態遷移が非決定的でないか(同じ入力に対して常に同じ遷移となるか)
  • エラーハンドリングが型に含まれているか(例外をRの失敗ケースに変換しているか)

Generator型を正しく設計すれば、状態遷移とリトライ制御はもはやバグの温床ではなく、型システムに守られた信頼性の高い部品となります。
次の最終章では、これまで議論してきたすべての知見を総括し、日常開発で実践すべきベストプラクティスを簡潔にまとめます。

まとめ:三つの型パラメータを省略せず書く習慣が堅牢なコードを生む

TypeScriptのGenerator型を完全指定することで型崩れを防ぎ品質向上を図る開発者のチームワーク

ここまで、TypeScriptのGenerator関数における型指定の重要性から、Y・R・Nそれぞれの詳細な設計パターン、さらにはジェネリックラッパーや実践的な状態遷移・リトライ制御まで、幅広いトピックを扱ってきました。
最終章である本章では、これらの知見を総括し、日常開発で即座に実践できるベストプラクティスを簡潔かつ明瞭に整理します。
Generatorは決して特殊な機能ではなく、適切に型付けすれば通常の関数やクラスと同様に信頼性の高い部品となります。
その鍵は、三つの型パラメータを「省略しない」というたった一つの習慣に集約されます。

型パラメータ省略がもたらす負の連鎖を振り返る

まず、型パラメータを省略した場合に何が起きるかを再確認しましょう。Generator<number> のように一部だけ指定すると、残りのRとNはunknownとなります。この状態では、returnの最終値は不明のまま、nextの引数は何でも許容され、結果として呼び出し側は型アサーションや実行時チェックで穴埋めせざるを得ません。その穴埋めはプロジェクトの規模が大きくなるほど増殖し、リファクタリングのたびに予期せぬエラーを誘発します。型崩れは決して「動かない」という形では顕在化せず、「動くが正しくない」というより厄介な形で品質を侵食します。

三つの型パラメータをすべて明示するという絶対原則

この連鎖を断つために、私たちが取るべき行動は極めてシンプルです。
Generator関数を定義する際は、必ず Generator<Y, R, N> の形式で三つの型パラメータをすべて指定すること。
たとえRやNがvoidであっても、Generator<SomeType, void, void> と明記することで「このGeneratorは最終値を返さず、外部入力も受け付けない」という意図が明確になります。
これにより、後日そのGeneratorを利用する開発者は、型定義を読むだけで振る舞いの全体像を把握できます。

各型パラメータにふさわしい具体的な型を選ぶ指針

それぞれのパラメータには、以下のような型を選ぶのが実践的です。

  • Y(Yield型) :yieldする値が単純な場合はリテラルユニオン、複雑なデータを伴う場合は判別ユニオン型を採用します。拡張性を考慮し、ジェネリック型エイリアスを用意しておくと再利用に便利です
  • R(Return型) :終了状態は必ず判別ユニオンで表現し、kind や status といった判別プロパティを含めます。成功・失敗・キャンセルなど、取りうるすべての終了シナリオを網羅してください
  • N(Next型) :next() に引数を渡す設計なら、コマンド型やオブジェクトユニオンで許可される値を限定します。引数を渡さない場合は void を指定し、誤った呼び出しをコンパイル時にブロックします

型指定を習慣化するための具体的なワークフロー

では、この習慣をどのように日常開発に組み込むか。
私は以下の三つのアクションを推奨します。

  • コーディング規約に「Generatorの型パラメータは三つすべてを明示すること」と明記し、レビューでチェックリスト化する
  • ESLintのカスタムルールや @typescript-eslint/typedef を利用して、型パラメータの省略を警告する
  • 新規にGeneratorを書くたびに、まず Generator<any, any, any> と書き出し、その後順次各パラメータを具体型に置き換えるテンプレートを用意する

これらのプロセスを踏むことで、型指定は「面倒な作業」から「設計の自然な一部」へと変わります。

型安全性がもたらす副次的効果:ドキュメント代替とリファクタリング耐性

型パラメータを完全に指定することのメリットは、コンパイル時のエラー検出だけに留まりません。
型定義そのものが最新の仕様書として機能し、開発者はJSDocや別途ドキュメントを参照しなくても、Generatorの入出力契約を即座に理解できます。
また、大規模リファクタリング時にGeneratorの内部実装を変更しても、型パラメータとの整合性が保たれていれば、呼び出し側への影響をコンパイラが正確に教えてくれます。
これは、テストカバレッジだけでは得られない静的検証による安心感です。

最後に:Generatorは怖くない、型で制御すれば強力な武器になる

多くの開発者がGeneratorを「難解」「特殊」と敬遠するのは、その双方向性と制御フローの複雑さに起因します。
しかし、TypeScriptの型システムはその複雑さを完全にラップし、呼び出し側と実装側の間に明確なインターフェースを提供します。
Y・R・Nという三つの視点でGeneratorを設計することは、関数の引数と戻り値に型を付けるのと本質的に変わりません。
一度この習慣が身につけば、Generatorは非同期フローや状態管理のための、最も柔軟で型安全な道具の一つとなるでしょう。

本記事で紹介したパターンやテクニックを、すべて一度に完璧に取り入れる必要はありません。
まずは次に書く一つのGenerator関数で、三つの型パラメータをすべて埋めてみてください。
その小さな習慣が、やがてコードベース全体の堅牢性を押し上げる原動力になります。
型崩れに悩まされない、クリーンで予測可能な開発体験を、ここから始めましょう。

コメント

タイトルとURLをコピーしました