1. ホーム
  2. TypeScript

【TypeScript】ジェネレーター関数(function*)に型を付ける方法|Generator 型と yield を解説

Share

ジェネレーター関数(function*)は JavaScript でもあまり書く機会がなく、いざ TypeScript で型を付けようとすると「Generator の型引数が3つもあるけれど、どれに何を書けばいいのか分からない」となりがちです。実は3つの型引数はそれぞれ yieldreturnnext() という別々の出入り口に対応しており、その対応さえ分かれば迷うところはありません。この記事では function* の基本的な動きから、Generator<T, TReturn, TNext> の書き方、yield* による委譲、async function*、そして古い target で出るエラーまでを順に解説します。

function* は「途中で止まる関数」

型の話に入る前に、ジェネレーター関数の動きを押さえておきます。function* と書いた関数は、呼び出しても中身が実行されません。代わりにジェネレーターオブジェクトが返り、その next() を呼ぶたびに、次の yield にぶつかるところまで実行して一時停止します。

basic.ts
function* countUp() {
  console.log('開始');
  yield 1;
  console.log('1 と 2 のあいだ');
  yield 2;
  console.log('終了');
}

// この時点では中身はまだ1行も実行されていない
const gen = countUp();

console.log(gen.next()); // 開始 → { value: 1, done: false }
console.log(gen.next()); // 1 と 2 のあいだ → { value: 2, done: false }
console.log(gen.next()); // 終了 → { value: undefined, done: true }

ログの順番を見ると分かるとおり、countUp() を呼んだ時点では「開始」すら出力されません。最初の next() でようやく先頭から yield 1 までが動き、そこで関数の実行が凍結されます。この「値を1つずつ、必要になったタイミングで作る」性質があるため、巨大なデータを少しずつ処理したいときや、終わりのない連番を扱いたいときに使われます。

Generator<T, TReturn, TNext> の3つの型引数

ジェネレーター関数の戻り値に付ける型が Generator<T, TReturn, TNext> です。3つもあると身構えてしまいますが、それぞれ「値がどこを通るか」に対応しているだけです。ジェネレーターには yield で外へ出す値、return で最後に返す値、next() から中へ入れる値という3方向の出入り口があり、型引数はその順番に並んでいます。

型引数意味その型が現れる場所
Tyield で外に渡す値の型yield xxfor...of で受け取る変数
TReturnreturn で最後に返す値の型return xxdone: true のときの value
TNextnext() の引数として渡せる値の型yield という式そのものの評価結果

いちばん単純な例として、数値を yield して最後に文字列を return し、next() には何も渡さないジェネレーターを書くとこうなります。next() に渡す値がないときは TNextundefined にしておきます。

types.ts
// T = number, TReturn = string, TNext = undefined
function* countUp(): Generator<number, string, undefined> {
  yield 1;
  yield 2;
  return '完了';
}

ここで yield 'x' と文字列を書いたり return 0 と数値を返したりすると、その場でエラーになります。ジェネレーターは呼び出し側とのやり取りが分かりにくくなりやすいので、この型注釈が仕様書の代わりになってくれます。

型注釈を省いたときの推論

戻り値の型を書かなくても、TypeScript は yieldreturn の内容から型を推論します。たとえば yield 1 だけを持つ関数は Generator<number, void, unknown> と推論されます。ただし TNextunknown にしかならない点に注意してください。next() に何が渡されるかは関数の中身からは分からないため、推論のしようがないのです。

そのため、const value = yield 1; のように yield の結果を使おうとすると、strict 設定では「'yield' expression implicitly results in an 'any' type because its containing generator lacks a return-type annotation.」というエラーになります。next() で値を受け取る設計にするなら、戻り値の型注釈は必須だと考えてください。

next() に渡した値は yield 式の結果になる

3つの型引数のうち、いちばんイメージしにくいのが TNext です。ジェネレーターは値を外に出すだけでなく、next(値) で外から値を送り込むこともできます。送り込まれた値は、停止していた yield という式そのものの値になって処理が再開されます。

next-in.ts
// 質問(string)を yield し、回答(string)を next で受け取る
function* dialogue(): Generator<string, string, string> {
  const name = yield 'お名前は?';        // name: string
  const age = yield `こんにちは、${name}さん。年齢は?`;
  return `${name}さん(${age}歳)を登録しました`;
}

const g = dialogue();
console.log(g.next().value);        // お名前は?
console.log(g.next('田中').value);  // こんにちは、田中さん。年齢は?
console.log(g.next('30').value);    // 田中さん(30歳)を登録しました

const name = yield '...'name に入るのは、yield した文字列ではなく 次の next() に渡された値です。型引数の TNextstring にしているので namestring と推論され、g.next(123) のように違う型を渡せばエラーになります。

なお、最初の next() に渡した値はどこにも届きません。1回目の next() は「先頭から最初の yield まで」を実行するだけで、値を受け取る yield がまだ存在しないからです。この仕様は型では表現されていないので、実装するときに気をつけるところです。

next() の戻り値 IteratorResult を done で絞り込む

next() が返すのは IteratorResult<T, TReturn> という型で、donevalue の2つのプロパティを持ちます。重要なのは、これが単なるオブジェクト型ではなく done の値で中身が変わる判別可能なユニオンだという点です。done: false なら valueTdone: true なら valueTReturn になります。

result.ts
function* countUp(): Generator<number, string, undefined> {
  yield 1;
  return '完了';
}

const gen = countUp();
const result = gen.next(); // result: IteratorResult<number, string>

if (result.done) {
  // ここでは value: string(TReturn 側)
  const message: string = result.value;
  console.log(message);
} else {
  // ここでは value: number(T 側)
  const n: number = result.value;
  console.log(n);
}

done を確かめずにいきなり result.valuenumber として使おうとすると、string の可能性が残っているためエラーになります。面倒に感じるかもしれませんが、「終わったあとの値を数値として扱ってしまう」バグを型が防いでくれている、と考えると納得できるはずです。

for…of では return した値を受け取れない

ジェネレーターは for...of やスプレッド構文でそのまま回せます。ただし for...of が取り出すのは yield された値だけで、return した値は捨てられます。これは TypeScript の都合ではなく JavaScript の仕様で、done: true になった時点でループを抜けるため、そのときの value は使われないのです。

forof.ts
function* countUp(): Generator<number, string, undefined> {
  yield 1;
  yield 2;
  return '完了';
}

for (const n of countUp()) {
  console.log(n); // n: number(1, 2 のみ。'完了' は現れない)
}

const values = [...countUp()]; // values: number[] → [1, 2]

// return の値がほしいときは next() を自分で回す
const gen = countUp();
let result = gen.next();
while (!result.done) {
  console.log('yield:', result.value);
  result = gen.next();
}
console.log('return:', result.value); // 完了

for...of で受け取る変数の型が TReturn と混ざらず T だけになるのは、この仕様が型定義に反映されているためです。集計結果のような「最後にまとめて返したい値」がある場合は、上のように whiledone を見ながら回すか、そもそも return ではなく最後に yield する設計に変えるほうが素直です。

終わらないジェネレーターと yield* による組み合わせ

無限に続くジェネレーターは TReturn を never にする

ジェネレーターは呼ばれたぶんだけ値を作るので、while (true) で無限に値を出し続けることができます。値が必要になるまで計算されないため、無限であってもプログラムは止まりません。この場合は return に到達しないので、TReturn に「決して起こらない」を表す never を指定すると意図が伝わります。

infinite.ts
// 1, 2, 3, ... を無限に生成する
function* naturalNumbers(): Generator<number, never, undefined> {
  let i = 1;
  while (true) {
    yield i++;
  }
}

// 先頭 count 個だけ取り出す
function* take<T>(source: Iterable<T>, count: number): Generator<T, void, undefined> {
  let i = 0;
  for (const value of source) {
    if (i++ >= count) return;
    yield value;
  }
}

console.log([...take(naturalNumbers(), 5)]); // [1, 2, 3, 4, 5]

take() の引数を Generator ではなく Iterable<T> にしているのがポイントです。こうしておくと配列や Set も渡せるようになり、ジェネレーターどうしをつなげて使えます。無限ジェネレーターをそのまま [...naturalNumbers()] のように展開すると当然フリーズするので、必ず途中で打ち切る側の関数と組み合わせて使います。

yield* で別のジェネレーターに処理を委譲する

yield*(アスタリスク付き)は、別のジェネレーターやイテラブルに処理を丸ごと任せる構文です。委譲先が yield した値はそのまま呼び出し元を通り抜けて外に出ていきます。型のうえで面白いのは、yield* 式自体の値が委譲先の TReturn になることです。for...of では捨てられてしまう return の値を、yield* なら受け取れます。

delegate.ts
function* readHeader(): Generator<string, number, undefined> {
  yield 'header-1';
  yield 'header-2';
  return 2; // 読んだ行数を返す
}

function* readAll(): Generator<string, void, undefined> {
  // yield* の値は委譲先の TReturn(= number)
  const headerCount = yield* readHeader();
  console.log(`ヘッダーを ${headerCount} 行読みました`);

  // 配列などのイテラブルにも委譲できる
  yield* ['body-1', 'body-2'];
}

console.log([...readAll()]);
// ヘッダーを 2 行読みました
// [ 'header-1', 'header-2', 'body-1', 'body-2' ]

アスタリスクを付け忘れて yield readHeader() と書くと、ジェネレーターオブジェクトそのものを1つの値として yield することになります。Tstring と宣言していればすぐエラーになりますが、型注釈を省いていると気づきにくいミスです。

async function* と AsyncGenerator

asyncfunction* を組み合わせた async function* は非同期ジェネレーターで、中で await しながら値を yield できます。戻り値の型は AsyncGenerator<T, TReturn, TNext> で、型引数の意味は Generator とまったく同じです。違うのは next()Promise<IteratorResult<T, TReturn>> を返すことと、ループに for await...of を使うことです。

async.ts
type Post = { id: number; title: string };

// ページングされた API を、1件ずつ流れてくるように見せる
async function* fetchAllPosts(): AsyncGenerator<Post, void, undefined> {
  let page = 1;
  while (true) {
    const response = await fetch(`/api/posts?page=${page}`);
    const posts: Post[] = await response.json();
    if (posts.length === 0) return; // これ以上ないので終了

    for (const post of posts) {
      yield post;
    }
    page++;
  }
}

async function main() {
  // for await...of で受け取る(post: Post)
  for await (const post of fetchAllPosts()) {
    console.log(post.title);
  }
}

この書き方の利点は、呼び出す側がページングの存在を意識しなくてよくなることです。「何ページあるのか」「次のページはあるのか」といった事情はジェネレーターの中に閉じ込められ、外からは Post が1件ずつ流れてくるように見えます。for await...ofasync function* は ES2018 の機能なので、tsconfig.jsontargetes2018 以上にしておくと余計な変換コードが入りません。

Iterable / Iterator インターフェースとの関係

ジェネレーターが for...of で回せるのは、Generator 型が IteratorIterable の両方の条件を満たしているからです。next() を持つので反復子(Iterator)であり、さらに自分自身を返す [Symbol.iterator]() を持つので反復可能(Iterable)でもある、という二重の性質を持っています。

意味
Iterator<T>next() を持ち、値を1つずつ取り出せるもの
Iterable<T>[Symbol.iterator]() を持ち、for...of やスプレッドで回せるもの
IterableIterator<T>上の両方を満たすもの(ジェネレーターはこれに当てはまる)
Generator<T, TReturn, TNext>さらに return() / throw() まで備えた、ジェネレーター専用の型

この関係を利用すると、自作のクラスを for...of で回せるようにする実装がとても短く書けます。[Symbol.iterator]() をジェネレーターメソッド(メソッド名の前に *)として定義するだけで、next()done の管理をすべて言語側に任せられます。

iterable.ts
class Playlist {
  constructor(private tracks: string[]) {}

  // ジェネレーターメソッドとして Symbol.iterator を実装する
  *[Symbol.iterator](): Generator<string, void, undefined> {
    for (const track of this.tracks) {
      yield track;
    }
  }
}

const playlist = new Playlist(['A', 'B', 'C']);
for (const track of playlist) {
  console.log(track); // track: string
}
const all: string[] = [...playlist];

関数の戻り値としては、next() に値を渡さないなら Generator の代わりに IterableIterator<string> と書いても構いません。型引数が1つで済むぶん読みやすく、「呼び出し側は for...of で回すだけ」という意図も伝わります。逆に next() でのやり取りや return の値まで扱わせたい場合は、Generator で3つとも明示してください。

型エラーが出るときに見直すところ

yield の結果が any になると言われる

'yield' expression implicitly results in an 'any' type because its containing generator lacks a return-type annotation.」というエラーは、戻り値の型注釈がないジェネレーターで yield の結果を使ったときに出ます。前述のとおり TNext は推論できないため、Generator<number, void, string> のように3つとも明示すれば解消します。値を送り込まない設計なら、そもそも const x = yield ... と受け取らないようにするだけでも構いません。

can only be iterated through when using the ‘–downlevelIteration’ flag と言われる

tsconfig.jsontargetes5es3 のとき、ジェネレーターを for...of やスプレッド構文で回そうとすると TS2802 のこのエラーが出ます。ES5 には反復処理の仕組みがないため、TypeScript は for...of を素朴な添字ループに変換します。配列ならそれで動きますが、ジェネレーターは添字でアクセスできないので変換しきれないのです。

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["es2015", "dom"],
    "downlevelIteration": true
  }
}

downlevelIterationtrue にすると、Symbol.iterator を正しくたどるヘルパー関数が出力に追加され、ES5 でもジェネレーターを回せるようになります。そのぶん出力コードは増えるので、古いブラウザを切り捨てられる状況なら targetes2015 以上に上げるほうが根本的な解決です。なお、ジェネレーター関数を定義して next() を直接呼ぶだけなら target: "es5" のままでもコンパイルできます。引っかかるのは for...of やスプレッドで回したときだけです。

Cannot find name ‘Generator’ と言われる

GeneratorIterableIterator という名前自体が見つからないと言われる場合は、lib の設定が足りていません。これらの型は lib.es2015.generator.d.ts などで定義されているので、targetes5 にしたまま lib を指定していないと lib.es5.d.ts だけが読み込まれ、型が存在しない状態になります。上の設定例のように "lib": ["es2015", "dom"] を明示すれば解決します。

まとめ

function*yield で一時停止し、next() で再開する関数です。型は Generator<T, TReturn, TNext> で表し、Tyield する値、TReturnreturn する値、TNextnext() に渡す値、という3つの出入り口に対応しています。TNext は推論できないので、yield の結果を使うなら型注釈は必ず書いてください。next() が返す IteratorResultdone で絞り込むと value の型が TTReturn に分かれ、for...of で回した場合は T だけが得られて return の値は捨てられます。無限ジェネレーターの TReturnneveryield* の式の値は委譲先の TReturn、非同期版は AsyncGeneratorfor await...of。この対応さえ頭に入れておけば、ジェネレーターの型で迷うことはほとんどなくなります。

参考ページ