12k
All articles

大規模コードベースをFlowからTypeScriptへ移行する

FlowからTypeScriptへの一括・段階的な移行を比較。codemod、両チェッカーの併用、型の変換、移行を進める方法を解説します。

OpenReplay Team
OpenReplay Team
大規模コードベースをFlowからTypeScriptへ移行する

大規模なコードベースをFlowからTypeScriptへ移行するには、コードに手を付ける前に2つの戦略のどちらを採るかを決める必要があります。1つ目は、リポジトリ全体に対してcodemodを一度に実行する方法です。すでに完全にFlowで型付けされていて、短期間のコードフリーズに耐えられるコードに適しています。2つ目は、依存グラフの末端(リーフ)から段階的に変換していく方法で、数か月から数年にわたって2つの型チェッカーを並行稼働させることになります。

Flowのアップグレードは毎回時間がかかりすぎ、必要なlibdefは存在しないか古くなっており、新しく入ったメンバーからは「なぜこのリポジトリはTypeScriptではないのか」と繰り返し聞かれます。それでも、機能開発を四半期まるごと止めるという選択肢はありません。

本プレイブックでは、2つの戦略を比較し、段階的移行の期間中にFlowとTypeScriptをどう共存させるかを解説します。さらに、codemodではきれいに変換できないFlow固有の型の対応関係を整理し、移行作業を停滞させないための方法を紹介します。

重要なポイント

  • Pinterestは370万行のFlowコードを1回のcodemod実行によって8か月でTypeScriptへ変換し、両方の型システムから得られるBabelの出力がバイト単位で同一であることを確認しました。
  • Yelpは140万行のソースコード(SLoC)をパッケージ単位で3年7か月かけて移行し、型カバレッジをFlowでの83.15%からTypeScriptでの96.44%へと引き上げました。
  • TypeScriptにはexact object型がありません。余分なプロパティが拒否されるのは、オブジェクトリテラルを型付きの代入先に直接代入した場合だけです。
  • Flowのopaque typeはTypeScriptではブランド型(branded type)になり、$Shape<T>はPartial<T>になります。後者はFlow自身も現在推奨しているものです。
  • 段階的移行は、意欲的なチームが作業を終えた後に停滞します。残りのモジュールを「いくつのモジュールのブロックを解除できるか」で順位付けし、期日付きの締め切りを設定することで、移行を前進させ続けられます。

チームがいまもFlowからTypeScriptへ移行する理由

チームがFlowを離れるのは、それを取り巻くエコシステムが縮小したためです。サードパーティパッケージ向けのTypeScriptの型は簡単に見つかり、ツール類はTypeScriptを前提としており、Flowにとどまる年数が増えるほど将来の移行コストが膨らみます。2021年5月、FlowチームはオープンソースコミュニティよりもFacebook社内のコードベースを優先すると表明しました。Yelpはこの発表を決定的な要因の一つに挙げており、あわせてflow-typedと比べてDefinitelyTypedのカタログがはるかに大きいこと、独自のTypeScript型宣言を同梱するパッケージが増えていることも理由としています。

とどまることのコストはツール面にも表れます。Flowから離れたことで、Yelpはswcと@typescript-eslintを導入できるようになりました。また、Flowのコードベースの中にはPropTypesを使っているものもあります。これは別のレイヤーです。PropTypesは実行時にpropsを検証するのに対し、FlowとTypeScriptはビルド時に型をチェックします。そのため、PropTypesの扱いは型チェッカーの移行とは切り離して判断してください。

一括codemodか段階的移行か:どちらの戦略が適しているか

一括codemodによる移行は、すでに完全にFlowで型付けされていて、短期間のフリーズを許容できるコードベースに適しています。段階的移行は、機能開発を止められないモノレポに適していますが、その代償として2つの型チェッカーを数か月から数年にわたって並行稼働させることになります。

一括codemod段階的移行
前提条件Flowカバレッジ100%依存関係の境界が明確なパッケージ
フリーズ切り替え時に短期間なし
所要期間数か月の準備数年
作業負荷のピーク自動化スクリプトと検証相互運用ツールチェーンと社内への働きかけ
リスクの集中箇所切り替え当日ロングテール
典型的な失敗マージ時のビルドやパスの破損部分的に完了した状態での停滞
事例Pinterest:370万行、8か月Yelp:140万SLoC、3年7か月

Pinterestはまずフローカバレッジ100%を達成し、それによって単一コミットでの移行が可能になりました。続いてStripeのflow-to-typescript-codemodを実行しました。このツールは2段階で動作します。まずBabelベースのconvertコマンドがFlowの構文をTypeScriptに書き換え、次にts-morphをベースとしたfixコマンドが、残ったTypeScriptのエラーを修正、抑制、またはレポートします。Pinterestは切り替えまでにTypeScriptブランチをmasterから毎日、合計60回以上再構築し、手作業による修正はすべて1つのコミットにまとめてcherry-pickしました。Factorialは2022年2月の記事で、ハイブリッドなアプローチを紹介しています。まず疎結合な1つの領域を変換して、FlowとTypeScriptが本番環境で共存できることを示し、その後残りをKhan Academyのflow-to-tsで一気に変換しました。

FlowからTypeScriptへの移行では、どちらの戦略でも同じ等価性チェックを利用できます。型注釈はコードの実行前に除去されるため、正しく変換されていれば同じJavaScriptが生成されます。Pinterestは両方のソースのBabel出力のdiffを取ることで、これを証明しました。それでも本番環境で何かが壊れる場合、その原因はたいていファイル名の変更、globの更新、ビルド設定にあります。Pinterestでは、ハードコードされた.jsへの参照が100件近く見つかりました。各切り替え後に本番環境のセッションを確認すると、静的チェックでは見逃される、レンダリングされなくなったページやチャンク読み込みエラーを発見できます。

FlowとTypeScriptを並行稼働させる

段階的移行では、変換済みのTypeScriptモジュールが未変換のFlowコードをimportしてはなりません。そのため変換は依存グラフのリーフから始め、上位へと進めていきます。Yelpはflowgenを使い、変換済みの各パッケージの.d.ts出力を、それをまだimportしているFlowコード向けにFlow互換の型へ変換しました。逆方向、つまりFlowからTypeScriptの型宣言を生成するツールは見つからなかったため、最も深い位置にある社内パッケージから着手する必要がありました。Flowは隣にある.jsモジュールの代わりに.js.flowファイルを読み込むため、flowgenの出力の置き場所として最適です。現在ではMetaのflow-api-translatorがFlowのソースからTypeScriptの型定義を出力できますが、その作者は、Flowからの移行ではなく、Flowのまま維持されるライブラリを公開する用途を想定しています。

未変換のFlowファイルはTypeScriptのプログラムから除外し、Flowだけにチェックさせてください。allowJsとcheckJsはプレーンなJavaScript向けであり、Flowの型注釈が付いたファイル向けではありません。ESLintのルールは、typescript-eslintのセットアップに沿って、各モジュールを変換するたびに移行していきます(Pinterestはflowtype/no-weak-typesを@typescript-eslint/no-explicit-anyに対応付けました)。エディタはワークスペースのTypeScriptバージョンを使用するよう設定してください。

CIチェックを使って、Flowのコードがこれ以上増えないようにします。

// scripts/no-new-flow.js
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");

const base = process.env.BASE_REF || "origin/main";
const added = execSync(`git diff --name-only --diff-filter=A ${base}...HEAD`, { encoding: "utf8" })
  .split("\n")
  .filter((f) => /\.(js|jsx)$/.test(f));

const offenders = added.filter((f) => /@flow\b/.test(readFileSync(f, "utf8")));

if (offenders.length) {
  console.error("New Flow files are not allowed. Write these in TypeScript:\n" + offenders.join("\n"));
  process.exit(1);
}

no-new-flow.jsスクリプトは、ベースrefと比較してブランチで追加されたファイルを一覧化し、そのいずれかに@flowが含まれていればジョブを失敗させます。ファイル名を変更する際はgit mv src/foo.js src/foo.tsを使い、git log --followで履歴をたどれるよう、リネームは内容の変更とは別のコミットにしてください。

直接変換できないFlowの型

Flowの4つの構文には、TypeScriptに1対1で対応するものがありません。exact object、variance sigil(変性を示す記号)、opaque type、そして$Shapeです。

Exact Object型

TypeScriptにはexact object型がありません。Flowのexact objectは通常のオブジェクト型になり、TypeScriptが余分なプロパティを拒否するのは、オブジェクトリテラルをその型に直接代入した場合だけです。Flowのオブジェクト型は0.202以降、デフォルトでexactになっています。{| |}は以前からある明示的な構文で、...はinexact objectを表します。

// Flow
type User = { id: string, name: string };        // exact by default
type LooseUser = { id: string, name: string, ... };
// TypeScript
type User = { id: string; name: string };
const u: User = { id: "1", name: "Ada", role: "admin" }; // error: excess property
const extra = { id: "1", name: "Ada", role: "admin" };
const v: User = extra; // allowed: not a fresh literal

変性(Variance)

Flowのプロパティに付ける+ sigilは、TypeScriptのreadonlyに対応します。-(書き込み専用)sigilには、TypeScriptに相当するものがありません。なお、TypeScriptではジェネリック型パラメータにin/out修飾子を付けることができます。

type Config = { readonly endpoint: string }; // Flow: { +endpoint: string }
type Sink = { value: number };               // Flow: { -value: number }; intent must be documented

Opaque型

Flowのopaque typeに相当するキーワードは、TypeScriptにはありません。一般的な代替手段はブランド型です。これは基になる型と一意のマーカープロパティとの交差型で、指定されたコンストラクタ関数だけが生成できるものです。Flowのopaque型は、定義されたファイル内では透過的に扱われます。一方、ブランドはどこでも強制されるため、コンストラクタ内でキャストが必要になります。

declare const userIdBrand: unique symbol;
export type UserId = string & { readonly [userIdBrand]: true };
export function toUserId(s: string): UserId {
  return s as UserId;
}
const bad: UserId = "u_1"; // error

$Shape

Flowは$Shapeを安全でないとして非推奨とし、Flow 0.201以降はPartial<T>を推奨しています。これはTypeScriptのPartial<T>にそのまま対応します。$Shapeのそれ以外の挙動に依存していた呼び出し箇所は、必ず確認してください。

function update(patch: Partial<User>) { /* ... */ } // Flow: $Shape<User>

よりシンプルなFlowからTypeScriptへの型の対応は、そのまま置き換えられます。

FlowTypeScript
?TT | null | undefined
mixedunknown
$ReadOnly<T>Readonly<T>
$Keys<T>keyof T
$ReadOnlyArray<T>ReadonlyArray<T>
React.NodeReact.ReactNode

flow-typedのlibdefを置き換える

パッケージを変換したら、そのflow-typedのlibdefを削除します。依存パッケージがpackage.jsonのtypesフィールドを通じて型を同梱している場合はそれを使い、同梱していない場合はDefinitelyTypedから@types/*をインストールします。社内パッケージやメンテナンスされていないパッケージ向けに手書きしたlibdefは、.d.tsファイルとして書き直す必要があります。これらはすべての利用側をブロックするため、早めに着手してください。

移行を停滞させないための進捗計測

段階的移行は、熱心なチームが作業を終えた時点で停滞します。Yelpで残り約200パッケージの移行を前進させたのは、残りのFlowモジュールを「いくつのモジュールのブロックを解除できるか」で順位付けし、期日付きの変換リストを公開したことでした。毎週、次の3つの数値を追跡してください。

grep -rl --include='*.js' --include='*.jsx' '@flow' src | wc -l                    # Flow files left
find src \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.d.ts' | wc -l               # TS files
grep -ro '@ts-expect-error' src | wc -l                                            # suppressions

あわせて、移行前後の型カバレッジも計測します。ブロックの原因となっている各モジュールを、それを待っているFlowモジュールの数でスコア付けし、リストの上位にあるものを担当チームに持ち込みます。Yelpは、EOL(サポート終了)日に向けて四半期ごとの締め切りを設定しました。期日は1四半期ずれ込みましたが、プレッシャーは確かに効果を発揮しました。最後まで残るモジュールに備えて、自動抑制によるフォールバックを計画しておきましょう。Yelpの約6,000件の@ts-expect-errorディレクティブのうち、約2,000件はこの最終段階で生じたものです。

現実的な規模感:スプリントではなく数か月単位

大規模なFlowからTypeScriptへの移行には、最低でも数か月かかります。Pinterestのcodemodによる方法は8か月、Yelpの段階的な方法は3年半以上を要しました。strictフラグは、変換が完了してから段階的に有効にしてください。JavaScriptベースのコンパイラ(TypeScript 6.0以前)を使う場合について、Pinterestはtscのメモリ不足を防ぐためにNodeのヒープ上限を引き上げました。記事では実際に使用した値が示されていないため、以下の数値はあくまで一例として扱ってください。

NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit

この値は使用するマシンに合わせて調整してください。それでもPinterestでは、完全な型チェックに3〜4分かかったと報告されています。TypeScript 6.0はJavaScriptベースの最後のリリースであり、非常に大きなリポジトリでの型チェック速度が大きく変わるのはネイティブのTypeScript 7コンパイラからです。次のステップとして、@flowファイルの数を数え、カバレッジが100%に達しているかを確認し、その数値に基づいて戦略を選択してください。

よくある質問

$FlowFixMeコメントは、TypeScriptでは@ts-expect-errorと@ts-ignoreのどちらにすべきですか?

@ts-expect-errorを使用してください。どちらのディレクティブも次の行のエラーを抑制しますが、TypeScript 3.9で追加された@ts-expect-errorは、その行でエラーが発生しなくなると「Unused @ts-expect-error directive」エラーを報告します。そのため、型が改善されるにつれて不要になった抑制が表面化し、抑制の数は減る一方になります。@ts-ignoreは根本的な問題が修正された後も何も報告しないため、同じ行で新たに発生したエラーを隠してしまう可能性があります。

Flowの$PropertyTypeと$ElementTypeは、TypeScriptでは何に置き換えればよいですか?

インデックスアクセス型です。これはFlowとTypeScriptで同じ構文を使用します。$PropertyType<T, 'k'>はT['k']に、$ElementType<T, K>はT[K]になります。Flowはこの2つのユーティリティをバージョン0.155で非推奨とし、0.266で削除しました。変換前にeslint-plugin-fb-flowのuse-indexed-access-typeの自動修正を実行しておけば、codemodでこれらの型をそのまま引き継げます。

@babel/preset-flowを@babel/preset-typescriptに置き換えると、コードの型チェックは行われますか?

いいえ。BabelはTypeScriptの型をチェックせずに除去するだけなので、CIには引き続き別途tsc --noEmitのステップが必要です。Babel 8では、preset-typescriptのonlyRemoveTypeImportsオプションのデフォルトがtrueになっているため、Babelはimport typeが付いたimportだけを除去します。型のみのバインディングを通常のimportで読み込んでいる場合、それらは出力に残り、モジュール解決を壊す可能性があります。そうしたimportにはimport typeを付けるか、onlyRemoveTypeImportsをfalseに設定してBabel 7の挙動に戻してください。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.