blog

主にJavaScriptを書いています

flowtypeバッドノウハウ

flowtypeを使ってみて個人的にはまったところ。

v0.65.0時点での話です。仕様なのかバグなのかわからない話も含みます。

enumの罠

flowのenum(正しくはUnion Type)は条件分岐などで、含まれないはずの値と比較しているとエラーを出してくれる。

/* @flow */

type A = "hoge" | "huga"

const a: A = "hoge"

if(a === "humu") {
  console.log("hoge")
}

// 7: if(a === "humu") {
//            ^ string literal `humu`. This type is incompatible with
// 5: const a: A = "hoge"
//            ^ string enum

ただし、これはObjectのプロパティに対して働かない

/* @flow */

type A = "hoge" | "huga"

type Obj = {
  a : A
}

const b: Obj = {
  a: "hoge"
}

if(b.a === "humu") {
  console.log("hoge")
}
// No errors!

謎のエラーになるんじゃなくて"No errors!"になるのがたちが悪い。エラーが出ないもんだからコーディング中は型チェックされていると思い込んでしまうし、enumの定義が変わったときに検知することもできない。

解決策としては、単純に比較しないこと。例えば以下のように配列から検索したいコードを書く場合は、下記のようにすると例のごとく型チェックが働かないので、

/* @flow */

type UserState = "Active" | "Registered"
type User = {
  state: UserState
}

const users: Array<User> = [
  { state: "Active" },
  { state: "Registered" }
]

const activeUsers = users.find(user => user.state === "Actived")
// No errors!

以下のように関数化して、関数に渡す段階で型を保証するとか。完全ではないけど、いろんなパターンでfindするときなんかはこういうのもアリ。

/* @flow */

type UserState = "Active" | "Registered"
type User = {
  state: UserState
}

const users: Array<User> = [
  { state: "Active" },
  { state: "Registered" }
]

const findUser = (users: Array<User>, state: UserState) => {
  return users.find(user => user.state === state)
}

findUser(users, "Actived")
// Error!!!

役に立たないビルドイン関数

例えばObject.values()。これを使いたいときはだいたい{ [string]: T }のようなmapからTの配列を取り出したいというのに、

/* @flow */

type A = { a: string }

const array: { [string]: A } = {
  key1: { a: "hoge" },
  key2: { a: "huga" }
}

Object.values(array).map(value => value.a)
// 10: Object.values(array).map(value => value.a)
//                                             ^ property `a`. Property cannot be accessed on
// 10: Object.values(array).map(value => value.a)
//                                      ^ mixed

エラーである。まあ本来Objectのvalueなんて型を定めていないから当たり前ではあるが...。

しょうがなく型を保てる同じ関数をutility/object.jsみたいな感じで定義している。

const values = <T>(obj:{ [string]: T }): Array<T> => {
  return Object.keys(obj).map(key => obj[key])
}
type A = { a: string }

const array: { [string]: A } = {
  key1: { a: "hoge" },
  key2: { a: "huga" }
}

values(array).map(value => value.a)

他にもObject.assign()も型を壊す代表格。flowはT1 & T2 & ...のような可変長Intersection Typeは表現できないのでしょうがない。このような型を壊す関数は不用意に使わないよう、ESLintのno-restricted-propertiesで制限するのも手。

共変でない配列

これはエラーになる。

/* @flow */

type A = {
  price: number,
  a: string
}

type Base = { price: number }

const sum = (array: Array<Base>) => array.reduce((result, e) => result + e.price, 0)

const array: Array<A> = [
  { price: 100, a: "hoge" },
  { price: 200, a: "huga" }
]

sum(array)

なぜ TypeScript の型システムが健全性を諦めているかの記事にもあるとおり、flowtypeの配列は共変でないというのが原因。TypeScriptから入ったのでよくこれに引っかかる。型がゆるふわなJavaScriptにおいては配列が共変であるメリットは大きいと思うので残念なところではあるが...

妥協案としてはこういう風にGeneric Typeを使う書き方にする。

/* @flow */

type A = {
  price: number,
  a: string
}

type Base = { price: number }

const sum = <T>(array: Array<T>, priceDetector: T => number) => {
 return array.reduce((result, e) => result + priceDetector(e), 0)
}

const array: Array<A> = [
  { price: 100, a: "hoge" },
  { price: 200, a: "huga" }
]

sum(array, e => e.price)

追記

これでいけると指摘をもらいました。

type A = {
  price: number,
  a: string
}

type Base = { price: number }

const sum = <T: Base>(array: Array<T>) => array.reduce((result, e) => result + e.price, 0)

const array: Array<A> = [
  { price: 100, a: "hoge" },
  { price: 200, a: "huga" }
]

sum(array)

追記2

$ReadOnlyArrayを使ってもいけました

/* @flow */

type A = {
  price: number,
  a: string
}

type Base = { price: number }

const sum = (array: $ReadOnlyArray<Base>) => array.reduce((result, e) => result + e.price, 0)

const array: Array<A> = [
  { price: 100, a: "hoge" },
  { price: 200, a: "huga" }
]

sum(array)

黒魔術にしか見えない定義

これだ。

type DefaultOption = {
  hoge: string
}

type CustomOption = {|
  ...$Exact<DefaultOption>,
  ...{|
    humu: number
  |}
|}

Intersection doesn't work for exact object types のIssueにある方法だが、ExactTypeをIntersection Typeで結合する方法がこれしかない。これ初見で理解できる人いるんだろうか...

まとめ

よくflowtypeとTypeScriptの比較で、「flowtypeは1ファイルから導入できる」というのが利点と言われている。という自分もTypeScript派からそこに惹かれてflowtypeを使ってみたのだけど、その意見には若干同意しかねるというのが使ってみた感想。

イメージとしては、flowのカバレッジが全体の50%になるように途中から入れたとしても、恩恵としては体感20%程度、というようなイメージ。型というものは規約で縛ってレールを踏み外さないようにするもの、つまり型なしで書かれたコードは容易にレールを踏み外す。安易に型がコロコロ変わるような実装や、先のバッドプラクティスにあるようなflowの癖に合致せず暗黙のanyが生まれる実装をしてしまう。そうして結局anyだらけになったりして、得られる恩恵が少なくなる。

もちろんゼロと20%の間には大きな溝があるので、無いなら導入しかないのだが、途中から入れれるからと高をくくってないで初めから入れような!