FlowtypeでImmutable.Recordの型定義がつらい
先日 Gotanda.js という勉強会で Immutable.js と Flowtype についてLTさせてもらったのだけど、その補足というか詳細的な話でもあります。
Immutable.Record + Flowtype
Immutable.jsのRecordのよく紹介される使い方として、継承してイミュータブルなモデルクラスを作るというものがある。
import { Record } from 'immutable'; const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends FooRecord { getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c : false }); foo.a // "2" foo.set('a', '3').a // "3" foo.getSquaredB() // 9 foo.remove('a').a // "1"
ここにFlowtypeの型アノテーションを追加したい。Immutable@4.0.0-rc.2
, flow-bin@0.48.0
, 型定義ファイルはImmutable.jsにバンドルされているものを使ってやっていく。
// @flow import { Record } from 'immutable'; const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends FooRecord { a: string; b: number; c: boolean; getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c: false }); // エラー出てほしい const a: number = foo.a; const b: string = foo.getSquaredB(); const b2: string = foo.set('b', 4).getSquaredB();
しかし、これはうまくいかない。
$ flow src/im-flow.js:11 11: class Foo extends FooRecord { ^^^^^^^^^ function call. Expected polymorphic type instead of 11: class Foo extends FooRecord { ^^^^^^^^^ RecordClass Found 1 error error Command failed with exit code 2.
immtuable.js.flow
のRecordの定義部分を見てみる。
declare class Record { static <Values>(spec: Values, name?: string): RecordClass<Values>; constructor<Values>(spec: Values, name?: string): RecordClass<Values>; static isRecord: typeof isRecord; static getDescriptiveName(record: RecordInstance<*>): string; } declare interface RecordClass<T> { (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T; new (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T; } declare class RecordInstance<T: Object> { ... }
Record
のコンストラクタはRecordClass<T>
のinterface型を、RecordClass<T>
のコンストラクタはRecordInstance<T>
を返す- そのため
FooRecord
やそれを継承したFoo
はRecordClass<T>
のinterface型として解釈される Foo
はRecordInstance<T>
を継承したものとして解釈されてほしい- が、
RecordInstance<T>
はexportされておらず外部から利用できない
ということっぽい。あまりやりたくはないが、RecordInstance<T>
の定義だけ別ファイルとして .flowconfig
のlibsに追加した上で、 FooRecord
継承前に Class<RecordInstance<FooRecordValues>>
にキャストしてみる。(直接はキャストできないので一旦anyにキャストしている)
type FooRecordValues = { a: string; b: number; c: boolean; } class Foo extends ((FooRecord: any): Class<RecordInstance<FooRecordValues>>) { ...
結果
$ flow src/im-flow.js:27 v 27: const foo = new Foo({ 28: a: '2', 29: b: 3, 30: c: false 31: }); ^ unused function argument v---------------------------------------- 3: declare class RecordInstance<T: Object> { 4: size: number; 5: ...: 50: } ^ default constructor expects no arguments. See lib: src/record.js.flow:3 src/im-flow.js:34 34: const a: number = foo.a; ^^^^^ string. This type is incompatible with 34: const a: number = foo.a; ^^^^^^ number src/im-flow.js:35 35: const b: string = foo.getSquaredB(); ^^^^^^^^^^^^^^^^^ number. This type is incompatible with 35: const b: string = foo.getSquaredB(); ^^^^^^ string src/im-flow.js:36 36: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^ property `getSquaredB`. Property not found in 36: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^^^^^ RecordInstance Found 4 errors error Command failed with exit code 2.
いくつかは期待通りのエラーが得られたが、まだおかしい。
RecordInstance<T>
は constructor の引数が定義されていないset
やupdate
など新しいインスタンスを返すメソッドにおいて、返り値の型は継承前のRecordInstance<FooRecordValues>
となってしまう
ということで RecordInstance<FooRecordValues>
を継承したクラス定義を別途用意して、それにキャストするようにした。
// @flow import { Record } from 'immutable'; type FooRecordValues = { a: string; b: number; c: boolean; } declare class FooRecordInstance extends RecordInstance<FooRecordValues> { a: string; b: number; c: boolean; constructor($Shape<FooRecordValues>): this | void; getSquaredB(): number; } const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends ((FooRecord: any): Class<FooRecordInstance>) { getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c: false }); // エラー出てほしい const a: number = foo.a; const b: string = foo.getSquaredB(); const b2: string = foo.set('b', 4).getSquaredB();
かなりつらい書き方になってしまったが、一応は想定通りのエラーを得ることができた。
$ flow src/im-flow.js:39 39: const a: number = foo.a; ^^^^^ string. This type is incompatible with 39: const a: number = foo.a; ^^^^^^ number src/im-flow.js:40 40: const b: string = foo.getSquaredB(); ^^^^^^^^^^^^^^^^^ number. This type is incompatible with 40: const b: string = foo.getSquaredB(); ^^^^^^ string src/im-flow.js:41 41: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ number. This type is incompatible with 41: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^ string Found 3 errors error Command failed with exit code 2.
これでなんとか型定義できたように見えたが、大きな落とし穴があった。
// エラー出てほしい const c: string = foo.get('c'); foo.set('c', 1);
$ flow
No errors!
✨ Done in 0.21s.
こうなる理由は型定義がこのようになっているため。
get<K: $Keys<T>>(key: K): /*T[K]*/any; set<K: $Keys<T>>(key: K, value: /*T[K]*/any): this;
getの返り値やsetのvalueが any
と定義されているため。コメントアウト部分はお気持ちの表明で現状このような書き方はできない。
$PropertyType
というUtilityはあるがstring literalしか引数に取れないとのこと。
getterはプロパティアクセスを使うとしてもsetterはどうしようもなく、クソめんどくさい型定義を書いても型安全でないという厳しい結果になってしまった。
$ElementType について
PropertyTypeに関するIssueによると、 $ElementType
なるUtilityを使うことで string literal じゃなくても value の型が取れる模様。Release note やドキュメントに記載はないが diff を見ると v0.49.0 から入っているようなので、 Flowtype のバージョンを上げて型定義を書き換えてみた。
get<K: $Keys<T>>(key: K): $ElementType<T, K>;
set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this;
これで改めてこの例を試す。
// エラー出てほしい const c: string = foo.get('c'); foo.set('c', 1);
今度は想定通りのエラーを出すことができた。
$ flow src/im-flow.js:44 44: const c: string = foo.get('c'); ^^^^^^^^^^^^ boolean. This type is incompatible with 44: const c: string = foo.get('c'); ^^^^^^ string src/im-flow.js:45 45: foo.set('c', 1) ^ number. This type is incompatible with the expected param type of 12: set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this; ^^^^^^^^^^^^^^^^^^ boolean. See lib: src/record.js.flow:12 Found 2 errors error Command failed with exit code 2.
ということで、getter/setter の問題に関しては $ElementType を使うことで解決できそうではある。