JavaScript
TypeScript
flowtype
ECMAScript2015

flowtypeのclassとobject typeについて整理する

flowのclass/object型の対応が独特でハマったのでメモ。

前提 - nominalとstructuralな型付け

一般的な型システムには以下の二種類が存在する。

nominal typing

明示的にclass/interfaceを宣言し、それを元に型を判定する。
java/C#などがコレ。

structural typing

objectのキーに注目し、それが一致するかで型を判定する。
typescript、golangなどがコレ(らしい)。

flowの型システムには、その二種類の型付けが混在している。

Class - nominal typing

Classはnominalな型付けで判定される。

class User {
  id: string
}
class GuestUser extends User {
  id: string
  expire: string
}
class AdminUser {
  id: string
  password: string
}

const guest :User = new GuestUser() // work
const admin :User = new AdminUser() // error!  

java/C#などを触っていれば、特に違和感無いと思います。

Object type - structural typing

typeで宣言されるobject typeは、structuralな判定が行われる。

type UserParam = {
  id: string
}
type GuestUserParam = {
  id: string,
  expire: string
}
type AdminUserParam = {
  id: string,
  password: string
}

const guest :GuestUserParam = {
    id: 'id-1',
    expire: '2018-06-06T14:00:05.182Z'
}
const admin :AdminUserParam = {
    id: 'id-2',
    password: 'passw0rd!'
}

const userA :UserParam = guest // work
const userB :UserParam = admin  // work

これもflowの資料みていると違和感ありませんね。

Object type と class

この二つが混ざるとちょっと妙な動きをします。

class AdminUser {
  id: string
  password: string
}

type AdminUserParam = {
  id: string,
  password: string
}

const admin :AdminUserParam = {
    id: 'id-2',
    password: 'passw0rd!'
}

const userA :AdminUser = admin // error!

classで宣言された型は「インスタンスがそのクラスorサブクラスによって生成された型か」を判定するため、フィールドの値が同じでも型判定が失敗します。

class AdminUser {
  id: string
  password: string
  constructor(params){
    this.id = params.id
    this.password = params.password
  }
}

const userA :AdminUser = new AdminUser({id: '',
 password: ''})
const userB :AdminUser = Object.assign({},userA,{id: 'test'}) // error!
const userC :AdminUser = new AdminUser(Object.assign({},userA,{id: 'test'})) //work!

userBのような、Object.assignによるオブジェクトの更新も、classから生成されていないオブジェクトなのでエラーが出ます。

コレは結構紛らわしいですね。
userCのようにconstructorにもう一度つっこむと正常にパースされて型がつきます。オブジェクトスプレッドと合わせてnew AdminUser({...userA,id: 'test'})とか書くと見た目も良いかと思われます。

この特徴から、クラスを作ると以下のような運用になることが多いです。

AdminUser.js
export default class AdminUser {
  id: string
  password: string

  constructor(params: AdminUserParam){
    this.id = params.id
    this.password = params.password
  }
}

export type AdminUserParam = {
  id: string,
  password: string
}

ちょっと冗長ですね。もっといいやり方あったら教えてください。

ちなみに

typescriptはclassのインスタンスもstructuralに型付けがされるため、以下のようになります。

class AdminUser {
  id: string
  password: string
  constructor(params){
    this.id = params.id
    this.password = params.password
  }
}

const userA :AdminUser = new AdminUser({id: '',
 password: ''})
const userB :AdminUser = Object.assign({}, userA, {id: 'test'}) // work!
const userC :AdminUser = {id: '', password: ''} //work

上のようなややこしさが無くシンプルですね。
ただ、コレはコレでピュアオブジェクトとクラスインスタンスを型で区別できないという問題があったりします。一長一短ですね。

追記

@kinzal 様より、以下のようにすると重複なしでかけるとのコメントをいただきました!

AdminUser.js
export default class AdminUser {
  id: string
  password: string

  constructor(params: AdminUserParam){
    this.id = params.id
    this.password = params.password
  }
}

export type AdminUserParam = $Diff<AdminUser, {}>

ただ、これを使って集約したクラスを表現したところ、また悩みにブチ当たったので記事書きました。
次の記事はこちら