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%の間には大きな溝があるので、無いなら導入しかないのだが、途中から入れれるからと高をくくってないで初めから入れような!

ドキュメントを書く技術

READMEを始め、ソフトウェアのドキュメント全般を書く技術というものをもっと洗練させていきたい。要件定義書のようなものだけでなく、開発方針や設計方針、API定義などなど。

これらのドキュメントをしっかりと整備するだけで、レビューの質も上がり新しい人が入ったときもスムーズに意識のズレなく開発ができる。はずだが、なかなかドキュメントの上手い書き方や管理の仕方というものは、コーディングのそれとは違い議論が活発ではない。

最近試してみたこと

そういったドキュメントの中でも、"開発方針"や"設計思想"をどう残していくかということを考えている。それらを残しておくことで、コーディングのときも立ち戻る場所ができ、大きく道を踏み外さなくなる。

例えば、レイヤードアーキテクチャのようなものの"境界"をドキュメントにしていく。MVCでもクリーンアーキテクチャでも何でも良いけど、それらのアーキテクチャではそれぞれのレイヤの役割は定義されているものの、必ずしも理想通りにいくとは限らないし、理想を追求することだけが正ではない。そういったアプリケーション・組織独自の"境界"をドキュメントに残していく。

個人的に各レイヤ(controllersやmodelsといったディレクトリ)にひとつずつREADMEを置いていくのが好きなのだけど、最近はそこに「MUST(MUST NOT)」、「SHOULD(SHOULD NOT)」、「MAY」などを書くといいのでは?と思って書き始めている。

# Controllers

## MUST

- ファイル名はXXXControllersとすること
- メソッドはGETならfindXXX、POSTなら....
- このレイヤでリクエストパラメータをxx型に変換する

## SHOULD

- ビジネスロジックは書くべきでないが、xxxなケースは例外とする

MUST/SHOULD/MAYの表記などはRFCでも使われていてエンジニアには比較的馴染みがある(?)はず。READMEに書いてあればレビューも通せるし、新しくプロジェクトに入った人がドキュメントの場所がわからない、ということもない。

ドキュメントの再現性

設計指針というものは、人によって判断がわかれる"迷い"から生まれると思っている。だからこそ、その迷いが出た時に「これはControllerのSHOULDじゃないの?」みたいに一種のフレームワークに当てはめると考えやすくなる。

ドキュメントを書く技術というのは、同時にドキュメントを"書かせる"技術でもある。コーディングと全く同じで、自分だけが書けても意味がなく、誰が書いても一定のレベルにすることが重要。そのためにはフォーマットを作ったりドキュメントの目的を共有したりして、適切な"枠"から無理なくドキュメントが生まれる環境を考えていきたい。

ドキュメントはコードから自動生成させていきたい

ドキュメントは腐りやすい

ドキュメントはどうやっても更新されなくなってしまう。Wikiに書こうか、Confluenceのほうがいいのか、色々置き場所を考えてはみるものの、大きく改善することはあまりない。

その理由のひとつが、コードとドキュメントのライフサイクルが違うこと。元来ドキュメントは「コーディングの前に書くもの」であったし、その意識はなくてもPull Requestに「あのドキュメントも更新しておきます」と書いている時点で、それはライフサイクルが合っていないものを、信頼性の低いプロトコルで同期を取ろうとしているようなもの。

それを解決する手段のひとつが、コードとドキュメントを同じリポジトリに置くということだけれど、READMEが大量にできるというのもそれはそれで見通しもメンテナンス性もあまり良くない。

必要とされるドキュメントの形はさまざま

それなら、とコードからドキュメントを自動生成させたいと思う。動いているコードは常に現状を正しく表しているので、そこから生成されるドキュメントも常に正しい(バグがないとは言っていない)。

JSDoc(やその源流のJavaDocなど)がそれに近いアプローチをとっているが、あまりうまく活用できたことがない。

コードは変えたけどコメントを直し忘れた、というケースがあるように、やはり「コードがコメントの通りに動いていること」は理想だけどそうならないケースがある。もちろんちゃんと運用したら良いツールなんだけど、そこが少しもったいない。

もうひとつは自由度の低さというのか、スコープの違いというのか。普段アプリケーションを作っている身としては、関数一覧が並んでいたらそんなに嬉しい?という気持ちになるし、生成されたドキュメントを見るよりコードを直接見たほうが早い。チーム外の人のためにわざわざドキュメントを生成してもほとんど見られないだろうし、なによりも別に知りたいことは関数一覧じゃなかったりする。

だからコードから生成しつつ、自由度が高い方法が欲しかった。

babylonでコードからドキュメントを生成する

とは言ってもJSDocのアプローチは捨てがたく、より面白いことができないかとbabylonに手を付けてみたら思いの外簡単だった。

babylonはあのbabelが使っているAST(Abstract Syntax Tree)を作るparser、平たく言うと「ソースコードを読み込んでその構造をオブジェクトにしますよ」というやつ。コードをbabylonにかけると、「クラス名はこれですよ」「メソッドはこんなのがありますよ」というのが簡単に取れるというもの。babelはこれを使って、コードを構造化したあと、いい感じに変換していい感じに出力している。

どれくらい簡単かというと、こういうコードがあれば

class Hoge {
  getMsg1() {
    return "hello"
  }

  getMsg2() {
    return "world"
  }
}

module.exports = Hoge

こういうコードを書くだけでメソッド一覧が取得できる。

const { parse } = require('babylon')
const traverse = require('babel-traverse').default
const fs = require('fs')

const code = fs.readFileSync('./index.js', 'utf-8')

const ast = parse(code)

let className
let methods = []
traverse(ast, {
  Class: path => {
    className = path.node.id.name
  },
  ClassMethod: path => {
    methods.push(path.node.key.name)
  }
})

console.log(`class: ${className}, methods: ${methods}`)
// => class: Hoge, methods: getMsg1,getMsg2

もちろんbabelのpluginでflowやJSXも読めるし、コード上のほぼすべての情報が取れるので、例えば「Componentを継承しているクラスの一覧がほしい」とかもすぐにできる。もちろんクラスやメソッド一覧だけじゃなくて、定数一覧でも何でも。

// pluginを使う
const ast = parse(code,{ plugins: ["flow"]})

これを使って例えば「外部サービスに接続している場所一覧」みたいなのをMarkdown形式でファイルに出力しておけば、自由度が高く必要な情報だけに絞ったドキュメントが自動生成される環境が作れる。

コメントも価値あるものとなる

そしてそれにdoctrineというJSDocパーサーを合わせるのも結構面白かった。結局JSDocに戻ってるじゃん、という気持ちにもなるが、例えば別ドキュメントへのリンクとか、あまり更新されないコメントであれば保守にも耐えられるだろう。たぶん。

先ほどの例でいくと、

/**
 * @see http://example.com
 */
class Hoge {
  getMsg1() {
    return "hello"
  }

  getMsg2() {
    return "world"
  }
}

module.exports = Hoge

というものをdoctrineで読み込むとこうなる。

const { parse } = require('babylon')
const traverse = require('babel-traverse').default
const fs = require('fs')
const doctrine = require("doctrine")

const code = fs.readFileSync('./index.js', 'utf-8')

const ast = parse(code)

let className
let link
let methods = []
traverse(ast, {
  Class: path => {
    className = path.node.id.name
    const comments = path.node.leadingComments.map(comment => comment.value)
    if (comments.length > 0) {
      const tags = doctrine.parse(comments[0], { unwrap: true }).tags
      link = tags.find(tag => tag.title === "see").description
    }
  },
  ClassMethod: path => {
    methods.push(path.node.key.name)
  }
})

console.log(`class: [${className}](${link})`)
// => class: [Hoge](http://example.com/juuyouna/reference)

どういうドキュメントがあると嬉しいのか、それはどういうアーキテクチャなら簡単に生成できるのか、アイデアがあれば何でも出来そうな気持ちに久しぶりに楽しくなった。