flow
immutable-js
flowtype

Recordの抽象クラス型安全 -Flow-

v4.0.0 の現在

immutable.Record によるモデル実装で、OOPとflow型定義についてのメモです。記事執筆時点で、rc.9が最新となっていますが、本稿では訳あって少し前の rc.4 を使います。理由は最後に。
sample: https://github.com/takefumi-yoshii/immutablejs-record-oop-example/

抽象クラスの型定義に挑む

immutable.Recordは、抽象クラスを作る事が出来ます。しかし、Flowtype や TypeScript による型安全を手に入れようとすると、一苦労かかります。まずは型無しのコードを見てみます。

super.js
import { Record } from 'immutable'

function collection (arg) {
  return {
    value: 0,
    ...arg
  }
}

function SuperClassFactory (arg) {
  return class extends Record(collection(arg)) {
    getValue () {
      return this.get('value')
    }
    setValue (value) {
      return this.set('value', value)
    }
  }
}

class SuperClass extends SuperClassFactory() {}

export { collection, SuperClassFactory, SuperClass }
sub.js
import { SuperClassFactory, collection as _collection } from './super'

function collection (arg) {
  return _collection({
    unit: '%',
    ...arg
  })
}

function SubClassFactory (arg) {
  return class extends SuperClassFactory(collection(arg)) {
    getUnit () {
      return this.get('unit')
    }
    setUnit (unit) {
      return this.set('unit', unit)
    }
  }
}

class SubClass extends SubClassFactory() {}

export { collection, SubClassFactory, SubClass }

利用時にはexportされたClassを利用します。ここまでのサンプルは割と出回っている記法なので、細かい解説は割愛します。

コレクション型定義

declare type でつらつらと定義します。

super.js
declare type C = {
  value: number;
}

export type { C }

サブクラスではスーパークラスのコレクション型定義を Intersection で結合します。普通ですね。

sub.js
import type { C as _C } from './super'

declare type C = _C & {
  unit: string;
}

export type { C }

コレクションファクトリー定義

コレクションファクトリーの戻り値型は、先に定義したコレクション型 と ジェネリック引数 を Intersection で指定します。

super.js
function collection<T> (arg: ?T): C & T {
  return {
    value: 0,
    ...arg
  }
}

export { collection }

サブクラスでは結合されたコレクション型 と ジェネリック引数を返すことになります。

sub.js
import type { C as _C, collection as _collection } from './super'

declare type C = _C & {
  unit: string;
}
function collection<T> (arg: ?T): C & T {
  return _collection({
    unit: '%',
    ...arg
  })
}

export type { C }
export { collection }

クラス定義

型無しコードではなかった declare class を定義します。RecordInstance を extends してつらつらと書きます。
RecordInstance については後述。

super.js
import { RecordInstance } from 'immutable'

declare class RI<T: Object> extends RecordInstance<T> {
  getValue (): number;
  setValue (value: number): T | this;
}

export type { RI }

サブクラスではスーパークラスの型定義を継承します。普通ですね。

sub.js
import { RI as _RI } from './super'

declare class RI<T: Object> extends _RI<T> {
  getUnit (): string;
  setUnit (unit: string): T | this;
}

export type { RI }

抽象クラスファクトリー型定義

抽象クラスファクトリーの Generic引数では、Objectとコレクション型を Union で指定します(<T: Object | C>) 。戻り値型はクラス型定義を引数にした Class Generic で指定します(Class<RI<T> | *>)。

super.js
import { Record } from 'immutable'

function SuperClassFactory<T: Object | C> (arg: ?T): Class<RI<T> | *> {
  return class extends Record(collection(arg)) {
    getValue (): number {
      return this.get('value')
    }
    setValue (value: number): T | this {
      return this.set('value', value)
    }
  }
}

export { SuperClassFactory }

サブクラスファクトリーメソッドで指定している RI<T>declare class RI<T> extends _RI<T> {} を参照することになります。

sub.js
import { SuperClassFactory } from './super'

function SubClassFactory<T: Object | C> (arg: ?T): Class<RI<T> | *> {
  return class extends SuperClassFactory(collection(arg)) {
    getUnit (): string {
      return this.get('unit')
    }
    setUnit (unit: string): T | this {
      return this.set('unit', unit)
    }
  }
}

export { SubClassFactory }

ここまでの実装で、抽象クラスのコレクションも this.get() this.set() でチェックしてくれる様になります。ただし、インスタンスを生成した後では無効になってしまいます。この点もう少し掘り下げたいところです。

空クラスにファクトリーメソッドを適用、export

最後にファクトリーメソッドを適用したclassをexportすれば完成です。型指定は必要はありません。サブクラスでもスーパークラスのメソッドまで辿り解釈してくれる様になりました。

super.js
class SuperClass extends SuperClassFactory() {}

export { SuperClass }
sub.js
class SubClass extends SubClassFactory() {}

export { SubClass }

利用するコードは以下の様になります。

test.js
// @flow

import { SuperClass } from './super'
import { SubClass } from './sub'

describe('SuperClass', () => {
  let model = new SuperClass({ value: 30 })
  test('#getValue', () => {
    expect(model.getValue()).toEqual(30)
  })
  test('#setValue', () => {
    model = model.setValue(20)
    // model = model.setValue('20') // $Expected Type Error.
    expect(model.getValue()).toEqual(20)
  })
})

describe('SubClass', () => {
  let model = new SubClass({ value: 30, unit: '%' })
  test('#getValue', () => {
    expect(model.getValue()).toEqual(30) // It works!
    // expect(model.getValueu()).toEqual(30) // $Expected undefined method Error.
  })
  test('#getUnit', () => {
    expect(model.getUnit()).toEqual('%')
  })
})

少し辛く改善点もあるが、割と安全になった

冗長な所が多いですが、抽象クラスを経由しても、それなりに型安全にすることが出来ました。IDEでもコードヒントが出る様になって最高ですね。冒頭の型無しコードの最終形態は以下の様になります。

super.js
import { Record, RecordInstance } from 'immutable'

declare type C = {
  value: number;
}

function collection<T> (arg: ?T): C & T {
  return {
    value: 0,
    ...arg
  }
}

declare class RI<T: Object> extends RecordInstance<T> {
  getValue (): number;
  setValue (value: number): T | this;
}

function SuperClassFactory<T: Object | C> (arg: ?T): Class<RI<T> | *> {
  return class extends Record(collection(arg)) {
    getValue (): number {
      return this.get('value')
    }
    setValue (value: number): T | this {
      return this.set('value', value)
    }
  }
}

class SuperClass extends SuperClassFactory() {}

export type { C, RI }
export { collection, SuperClassFactory, SuperClass }
sub.js
import { type C as _C, collection as _collection, RI as _RI, SuperClassFactory } from './super'

declare type C = _C & {
  unit: string;
}

function collection (arg) {
  return _collection({
    unit: '%',
    ...arg
  })
}

declare class RI<T: Object> extends _RI<T> {
  getUnit (): string;
  setUnit (unit: string): T | this;
}

function SubClassFactory<T: Object | C> (arg: ?T): Class<RI<T> | *> {
  return class extends SuperClassFactory(collection(arg)) {
    getUnit (): string {
      return this.get('unit')
    }
    setUnit (unit: string): T | this {
      return this.set('unit', unit)
    }
  }
}

class SubClass extends SubClassFactory() {}

export type { C, RI }
export { collection, SubClassFactory, SubClass }

【残っている課題点】

  • instance new した後は collection を key で追えない
  • declare class は interface ではないため、制約としての効果はない
  • declare class と実装を別で書く2度手間感
  • 今後 RecordInstance が type-definitions で export されていないと辛い

v4.0.0 rc版と RecordInstance

【rc.3】 $ElementType の適用、RecordInstance が export

こちらのブログで言及されている内容が反映されていました。UtilityType の $ElementType 取り込みで、コレクションの型が解釈される様になりました。未定義のコレクションをget・setしようとしても怒ってくれます。ちょっとしたtypoに気づいたり出来るので、これは有り難いです。

修正コミット:https://github.com/facebook/immutable-js/pull/1276/commits/f026feaa5721c685a0c83520b06fe964c593ee95

【rc.5】 RecordFactory が登場、RecordInstance の export が廃止

RecordInstance とは、実装実態の無い型定義です。Record は class のファクトリー関数であり、独自のメソッドを生やしつつ型安全にするためには、rc.3 から rc.4 の間は、本稿サンプルの様に定義する必要がありました。rc.5 では、手短に定義出来る様に RecordFactory という定義が新設されています。あまり嬉しくないですね

Before: Class<RecordInstance<T>>
After: RecordFactory<T>

この時点で、RecordInstance の export はお役御免と判断され廃止されました。これでは本稿で紹介している様な抽象クラスを実装することが厳しい自体に…。これがrc.4を使い解説した理由です。v4.0.0 stable版が出たタイミングで RecordInstance が普通に利用出来るか分からないので、プルリク投げましたしました。自リポジトリの flowディレクトリに RecordInstance 定義だけ引っ張ってくるのは、やはりやりたくないですね…。

余談

今回の様なコードの場合は webpack を使わずとも jest だけで検証出来るので便利ですね。jest お薦めです。ですが、declare export class という宣言では何故か jest で怒られてしまいます。そのため、ファイル末で export type {} で class定義を export する必要があります。(それなのに、import type { Klass } ではなく、import { Klass } としなければ型定義を読み込めないのは、ちょっと分かり辛いですね。実装実態とClass定義を同時に読み込みたいがために、こうなっているのだとは思いますが。)