メインコンテンツにスキップ

Flow でのジェネレーターの型付け

Flow 0.14.0 には、ジェネレーター関数のサポートが含まれています。ジェネレーター関数は、JavaScript プログラムに独自の機能を提供します。それは、実行を一時停止および再開する機能です。この種の制御は、async/await(Flow で既にサポートされている、今後の機能)への道を開きます。

ジェネレーターを説明する素晴らしい資料は既にたくさん作成されています。ここでは、静的型付けとジェネレーターの相互作用に焦点を当てます。ジェネレーターに関する情報については、以下の資料を参照してください。

  • Jafar Husain は、ジェネレーターをカバーする非常に明快で分かりやすい講演を行いました。ジェネレーターについて触れている箇所にリンクしましたが、講演全体を強くお勧めします。
  • Axel Rauschmayer による包括的な書籍『Exploring ES6』は、内容がオンラインで無料で公開されており、ジェネレーターに関する章があります。
  • 由緒ある MDN には、Iterator インターフェースとジェネレーターについて説明した便利なページがあります。

Flow では、Generator インターフェースには YieldReturnNext の 3 つの型パラメーターがあります。Yield は、ジェネレーター関数から yield される値の型です。Return は、ジェネレーター関数から返される値の型です。Next は、Generator 自体の next メソッドを介してジェネレーターに渡される値の型です。たとえば、Generator<string,number,boolean> 型のジェネレーター値は、string を yield し、number を返し、呼び出し元から boolean を受け取ります。

任意の型 T に対して、Generator<T,void,void> は、Iterable<T>Iterator<T> の両方です。

ジェネレーターの独自性により、無限シーケンスを自然に表現できます。自然数の無限シーケンスを考えてみましょう。

function *nats() {
let i = 0;
while (true) {
yield i++;
}
}

ジェネレーターはイテレーターでもあるため、手動でジェネレーターを反復処理できます。

const gen = nats();
console.log(gen.next()); // { done: false, value: 0 }
console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: false, value: 2 }

done が false の場合、value はジェネレーターの Yield 型になります。done が true の場合、value はジェネレーターの Return 型になるか、コンシューマーが完了値を過ぎて反復処理した場合は void になります。

function *test() {
yield 1;
return "complete";
}
const gen = test();
console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: true, value: "complete" }
console.log(gen.next()); // { done: true, value: undefined }

この動作のため、手動で反復処理すると型付けが難しくなります。手動の反復処理によって nats ジェネレーターから最初の 10 個の値を取得してみましょう。

const gen = nats();
const take10: number[] = [];
for (let i = 0; i < 10; i++) {
const { done, value } = gen.next();
if (done) {
break;
} else {
take10.push(value); // error!
}
}
test.js:13
13: const { done, value } = gen.next();
^^^^^^^^^^ call of method `next`
17: take10.push(value); // error!
^^^^^ undefined. This type is incompatible with
11: const take10: number[] = [];
^^^^^^ number

Flow は、valueundefined になる可能性があると警告しています。これは、value の型が Yield | Return | void であり、nats のインスタンスでは number | void に簡略化されるためです。動的な型テストを導入して、done が false の場合、value は常に number になるという不変条件を Flow に納得させることができます。

const gen = nats();
const take10: number[] = [];
for (let i = 0; i < 10; i++) {
const { done, value } = gen.next();
if (done) {
break;
} else {
if (typeof value === "undefined") {
throw new Error("`value` must be a number.");
}
take10.push(value); // no error
}
}

タグ付きユニオンを改良するための番兵として done 値を使用することにより、上記の動的な型テストを不要にする未解決の問題があります。つまり、donetrue の場合、Flow は value が常に Yield 型であり、それ以外の場合は Return | void 型であることを認識します。

動的な型テストがなくても、このコードは非常に冗長で、意図が分かりにくいものです。ジェネレーターはイテラブルでもあるため、for...of ループも使用できます。

const take10: number[] = [];
let i = 0;
for (let nat of nats()) {
if (i === 10) break;
take10.push(nat);
i++;
}

これはかなり良いでしょう。for...of ループ構造は完了値を無視するため、Flow は nat が常に number になることを理解します。ジェネレーター関数を使用して、このパターンをさらに一般化してみましょう。

function *take<T>(n: number, xs: Iterable<T>): Iterable<T> {
if (n <= 0) return;
let i = 0;
for (let x of xs) {
yield x;
if (++i === n) return;
}
}

for (let n of take(10, nats())) {
console.log(n); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}

take ジェネレーターのパラメーターと戻り値の型を明示的に注釈していることに注意してください。これは、Flow が完全にジェネリックな型を理解できるようにするために必要です。これは、Flow が現在、完全にジェネリックな型を推論せず、代わりに下限を累積して、ユニオン型にするためです。

function identity(x) { return x }
var a: string = identity(""); // error
var b: number = identity(0); // error

上記のコードは、stringnumber が、x によってバインドされた値の型を記述する型変数への下限として Flow が追加するため、エラーが発生します。つまり、Flow は、identity の型が (x: string | number) => string | number であると信じています。これは、実際にその関数を通過した型であるためです。

ジェネレーターのもう 1 つの重要な機能は、コンシューマーからジェネレーターに値を渡す機能です。提供された関数を使用してジェネレーターに渡された値を減らすジェネレーター scan を考えてみましょう。この scanArray.prototype.reduce に似ていますが、各中間値を返し、値は next を介して命令的に提供されます。

最初のパスとして、これを次のように記述できます。

function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
let acc = init;
while (true) {
const next = yield acc;
acc = f(acc, next);
}
}

この定義を使用して、命令型 sum プロシージャを実装できます。

let sum = scan(0, (a,b) => a + b);
console.log(sum.next()); // { done: false, value: 0 }
console.log(sum.next(1)); // { done: false, value: 1 }
console.log(sum.next(2)); // { done: false, value: 3 }
console.log(sum.next(3)); // { done: false, value: 6 }

ただし、上記の scan の定義をチェックしようとすると、Flow は警告を表示します。

test.js:7
7: acc = f(acc, next);
^^^^^^^^^^^^ function call
7: acc = f(acc, next);
^^^^ undefined. This type is incompatible with
3: function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
^ some incompatible instantiation of T

Flow は、sum の例では number であるはずの予期された T ではなく、値 nextvoid である可能性があると警告しています。この動作は、型安全性を確保するために必要です。ジェネレーターを準備するには、コンシューマーは最初に引数なしで next を呼び出す必要があります。これを考慮して、Flow は next の引数をオプションとして認識します。これは、Flow が次のコードを許可することを意味します。

let sum = scan(0, (a,b) => a + b);
console.log(sum.next()); // first call primes the generator
console.log(sum.next()); // we should pass a value, but don't need to

一般に、Flow はどの呼び出しが「最初」であるかを認識しません。最初の next に値を渡すことはエラーであり、後続の next に値を渡さないこともエラーである必要がありますが、Flow は妥協して、ジェネレーターに潜在的な void 値に対処することを強制します。要するに、型 Generator<Y,R,N> のジェネレーターと型 Y の値 x が与えられた場合、式 yield x の型は N | void です。

実行時に void でない不変条件を強制する動的な型テストを使用するように定義を更新できます。

function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
let acc = init;
while (true) {
const next = yield acc;
if (typeof next === "undefined") {
throw new Error("Caller must provide an argument to `next`.");
}
acc = f(acc, next);
}
}

型付きジェネレーターを扱う場合、もう 1 つ重要な注意点があります。ジェネレーターから yield されるすべての値は、単一の型で記述する必要があります。同様に、next を介してジェネレーターに渡されるすべての値は、単一の型で記述する必要があります。

次のジェネレーターを考えてみましょう。

function *foo() {
yield 0;
yield "";
}

const gen = foo();
const a: number = gen.next().value; // error
const b: string = gen.next().value; // error

これは完全に有効な JavaScript であり、値 ab は実行時に正しい型になります。ただし、Flow はこのプログラムを拒否します。ジェネレーターの Yield 型パラメーターには、number | string の具体的な型があります。イテレーターの結果オブジェクトの value プロパティの型は number | string | void です。

ジェネレーターに渡された値についても同様の動作を観察できます。

function *bar() {
var a = yield;
var b = yield;
return {a,b};
}

const gen = bar();
gen.next(); // prime the generator
gen.next(0);
const ret: { a: number, b: string } = gen.next("").value; // error

ret には、実行時に注釈された型がありますが、Flow もこのプログラムを拒否します。ジェネレーターの Next 型パラメーターには、number | string の具体的な型があります。したがって、イテレーターの結果オブジェクトの value プロパティの型は void | { a: void | number | string, b: void | number | string } になります。

動的な型テストを使用してこれらの問題を解決することは可能ですが、別の実用的なオプションとして、any を使用して型安全性の責任を自分で負うことができます。

function *bar(): Generator {
var a = yield;
var b = yield;
return {a,b};
}

const gen = bar();
gen.next(); // prime the generator
gen.next(0);
const ret: void | { a: number, b: string } = gen.next("").value; // OK

(注釈 GeneratorGenerator<any,any,any> と同等です。)

ふう!これが、独自のコードでジェネレーターを使用するのに役立つことを願っています。また、JavaScript のような非常に動的な言語に静的分析を適用することの難しさについて少し洞察が得られたことを願っています。

要約すると、静的に型付けされた JS でジェネレーターを使用するための教訓は次のとおりです。

  • ジェネレーターを使用して、カスタム イテラブルを実装します。
  • 動的な型テストを使用して、yield 式のオプションの戻り値の型をアンパックします。
  • 複数の型の値の yield または受信を行うジェネレーターは避けるか、any を使用します。