開発を止めずに Flow を TypeScript に移行する手法

Flow から TypeScript へ

こんにちは、freee人事労務開発チームでエンジニアをしている fiahfy です。
現在、freee人事労務のフロントエンドコードはほぼ TypeScript で記述されていますが、以前は Flow のコードが大部分を占めていました。
今回は、約 1200 ファイル程度あった Flow コードを開発を止めずに TypeScript に移行した話を紹介します。

TypeScript の導入

私が初めて開発に携わった当初、フロントコードは主に Flow で記述されていました。
私自身 Flow を触っていなかったというのと、エディタ周りのツールの影響であまり開発生産性が高くないと感じ TypeScript の導入を行うことにしました。
導入後は新規コンポーネントを TypeScript で作成する様にし、記述のしやすさや Flow との書き方の違い等を確かめました。

TypeScript でコードを書くうちに、型推論が Flow に比べてかなり賢く、エディタ周りの拡張が充実しており、開発のしやすさを感じました。
(Flow の型推論がイマイチと感じた理由には、Flow を最新 version で利用していなかったというのもあったのですが、実際に update を行おうとするとかなりの Error が発生し、更新負荷が高かったというのもあります。)

TypeScript 単体で利用する分には全く問題なかったのですが、一方で Flow と TypeScript を混在した状態で利用する場合は import 周りでの型の受け渡しに問題があります。
以下のような、Flow から TypeScript を import するコードです。

math.ts

export const add = (a: number, b: number) => a + b;

main.js

// @flow
import { add } from './math';  // TypeScript で記述されているコード

const result = add(1, 2) // 引数、戻り値に対して型が効かない

この場合 add 関数が any 型となり、引数、戻り値共に型が効かない状態になってしまいます。
一応、.flow.js ファイルや .d.ts ファイルなどを配置して型情報を提供することで、Flow / TypeScript の言語間を隔てて型情報の共有をすることは可能ではあります。
しかし、この方法は手動による型定義のメンテナンスが必要となり、修正漏れが発生するとかえって型環境全体が信頼できないものとなっていくリスクをはらんでいます。そのため、自動変換による手法を模索することにしました。

変換ツールの検証

Flow コードを TypeScript コードへ変換するツールはいくつか存在し、その中で以下のツールを実際に利用して変換結果を検証しました。

変換結果を検証するために、スタンダードな型パターンを含むサンプルコードで検証を行いました。
サンプルコードを実際に変換してみて結果を検証した結果 babel-plugin-flow-to-typescript が一番求めているような変換をしてくれることがわかりました。
変換自体はこのツールを使えばおおよそうまくいきそうなことがわかりましたが、babel plugin となっておりこのままだと少々利用しづらいため、この plugin を wrap するスクリプトを作成しました。

https://github.com/fiahfy/flow2ts

このスクリプトで Flow コードのファイルを指定し実行すると、同じ階層に TypeScript に変換されたファイルが出力されます。

$ npx @fiahfy/flow2ts src/components/MyComponent.js
Output /.../src/components/MyComponent.tsx

またこのスクリプト内で、機械的に対応できる型の調整を合わせて行い、後述の変換工程の負荷を減らしています。

部分的に変換を始める

このスクリプトによってFlow コードを TypeScript に変換する準備は整いました。
小さなサービスや、すでにほとんど修正が発生しないサービスであれば一定期間開発を止めて一度に変換する手法を取ることができますが、対象のサービスは比較的巨大なサービスで、修正も絶えず発生しているため開発を止めることができません。
そこで部分的に変換を実行していくステップを踏むことにしました。

対象サービスのフロントエンドコードの構成は以下のようになっており、機能ごとに1エントリーポイント = 1ディレクトリ(以降 feature directory と呼びます)となっており、依存は共通モジュールを除くとディレクトリ内だけで閉じている関係にしています。

src/
├── features  # 機能ごとの directory
│   ├── docs
│   ├── employees
│   └── settings
└── packages  # 共通モジュール

このように feature directory ごとに依存性を独立させている構成を取っているため、feature directory 単位でまとめて TypeScript 化を行えば、他の feature directory 内のコードに影響を与えることなく、また Flow と TypeScript の接する部分で型の弱化もすることなく、TypeScript 化を進めることができました。

TypeScript コードから Flow の型定義を生成

ここで問題となってくるのが共通モジュールへの依存による型周りの問題です。
共通モジュールは TypeScript 化が完了するまで、Flow/TypeScript どちらのコードからも参照されることになります。
この問題を解決するには、前述したような型定義ファイルを用意する必要がありますが、実コードと型定義ファイルの二重管理を余儀なくされ、さらに型定義ファイルの更新漏れにより型情報が誤ったものとなってしまう懸念もありました。

そこで共通モジュールを TypeScript に変換後、そのコードから Flow の型定義ファイルを自動生成するツールを作成し、型情報を担保する形を取りました。(同じチームの keik 氏作)

https://github.com/keik/generate-flow-types-from-ts-package

共通モジュールに修正を行った場合は、こちらのツールで型定義ファイルを生成し commit を行う必要があります。
上記の作業だけでは型定義ファイルの更新漏れが発生する可能性があるため、CIの方でも同様に型定義ファイルを生成し、同期が取れていない場合はCIで検知する仕組みを導入しました。

feature directory 単位での変換

共通モジュールの問題が解決したので、次のステップでは feature directory ごとに TypeScript 化を行っていくことができます。

前述の変換スクリプト(fiahfy/flow2ts)により TypeScript 変換自体は簡単に行えるのですが、実際は Flow での型付けが甘かったり、Flow 自体の問題で単純に変換しただけだと大量の TSError が発生します。

変換中は対象の feature directory 上での開発を止めるという選択肢もありましたが、やはり修正が絶えず発生しているサービスのため、以下のようなフェーズで進める方法を取りました。

prepare

変換までの事前準備を行うフェーズです。

変換スクリプトを手元で実行し TypeScript コードの出力結果を見て、Flow コードを調整、再度変換し、変換後に TSError を発生しないようにします。
つまり現状の Flow コード時点ではエラーが起きないが TypeScript 化後に発生する型エラーに対して、変換前後の Flow / TypeScript どちらのコードでもエラーが発生しないよう、 Flow コード時点で修正します。

単純に型のみを修正するだけでいいような Error もあれば、実コードに手を入れる必要のある修正もありました。
エラーの数によっては負荷の高い作業ですが、事前にこの対応を行っておくことで変換作業自体の時間をほぼ取ることなく conflict の発生を防げます。
結果的にはこの作業が移行作業の大半の時間を占めていました。

flow2ts

実際に変換を行うフェーズです。

このフェーズでは conflict の防止や万が一何かあったときの rollback を考慮して基本的には変換以外の修正は行わないようにしました。
具体的には以下のような手順で変換を行っていました。

# 変換
flow2ts <feature_directory>
# eslint autofix
eslint <feature_directory>/ --ext  .ts,.tsx --fix
# format
prettier <feature_directory>/**/*.{ts,tsx} --write
# 変換前のファイルや不要になった型定義ファイル削除
rm <feature_directory>/**/*.{d.ts,js,js.flow}

事前準備が済んでいるとこの作業は数回のコマンドを叩くだけで完了します。

cleanup

変換後のコードを調整するフェーズです。

動作には影響しませんが、変換スクリプトで出力されるコードはコメントがずれてしまったり、改行が意図していない箇所で挿入/削除されてしまうため、目につくところを手動で対応します。
また、prepare フェーズで any 型を使って TSError を回避していた箇所などを直せる範囲で対応します。

まとめ

上記の手順で TypeScript 移行を行ったことにより、日々の機能開発を止めることなく、バグも出さずに移行作業を完了することができました。
移行自体は完了しましたが、TypeScript の型システムの恩恵を最大限に受けるためにはまだまだ課題があります。

  • 変換の工程で一部の型を緩くしてしまった
  • 元々 any 型を多用してしまっている
  • バックエンドとの型の整合性の担保等

freee人事労務では上記の課題も含め、引き続きフロントエンドでより型安全に開発できる環境を整えていきたいと思います。