FlowtypeでImmutable.Recordの型定義がつらい

先日 Gotanda.js という勉強会で Immutable.js と Flowtype についてLTさせてもらったのだけど、その補足というか詳細的な話でもあります。

speakerdeck.com

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.flowRecordの定義部分を見てみる。

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 やそれを継承した FooRecordClass<T> のinterface型として解釈される
  • FooRecordInstance<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 の引数が定義されていない
  • setupdate など新しいインスタンスを返すメソッドにおいて、返り値の型は継承前の 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しか引数に取れないとのこと。

flowtypeのUtility Typeについて その1

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 を使うことで解決できそうではある。